dsh-plugin-tool-management 0.7.0 → 0.8.5

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 (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +19 -5
  3. package/README_EN.md +19 -5
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +31 -0
  21. package/lib/agents-md/service.js +41 -3
  22. package/lib/client.js +913 -415
  23. package/lib/compat/preset-reach.js +1 -1
  24. package/lib/compat/probe.js +12 -2
  25. package/lib/index.js +651 -31
  26. package/lib/mcp/state-section.js +90 -0
  27. package/lib/prompt-sections.js +148 -0
  28. package/lib/rules/archive-engine.js +151 -30
  29. package/lib/rules/archive.js +78 -28
  30. package/lib/rules/service.js +273 -41
  31. package/lib/skills/core.js +36 -1
  32. package/lib/skills/service.js +36 -10
  33. package/lib/subagents/catalog.js +90 -0
  34. package/lib/subagents/service.js +187 -20
  35. package/lib/subagents/tools.js +10 -3
  36. package/package.json +97 -97
  37. package/lib/rules/provider.js +0 -121
package/lib/index.js CHANGED
@@ -16,14 +16,17 @@ import { randomUUID } 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
18
  import { createAgentsMdService } from './agents-md/service.js';
19
- import { createRulesService } from './rules/service.js';
19
+ import { createRulesService, planMemoryExport } from './rules/service.js';
20
20
  import { createArchiveEngine } from './rules/archive-engine.js';
21
- import { createSubagentService, decideToolFilter } from './subagents/service.js';
21
+ import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
22
+ import { createSubagentCatalog } from './subagents/catalog.js';
23
+ import { SECTION_ORDER, createPromptSectionRegistrar } from './prompt-sections.js';
22
24
  import { isApprovalNever } from './approval-policy.js';
23
- import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
25
+ import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
24
26
  import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
25
27
  import { fenceRejection, secretOpRejection } from './http-fence.js';
26
28
  import { planOverrideCompaction } from './mcp/override-blocks.js';
29
+ import { createMcpStateCatalog, mcpStateSectionOf } from './mcp/state-section.js';
27
30
  import { hubPath, hubRoot, relocateEntries } from './hub.js';
28
31
  import { createScenePromptSync } from './scene-prompt-sync.js';
29
32
  import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
@@ -31,7 +34,8 @@ import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, pars
31
34
  import { homedir } from 'node:os';
32
35
  import { spawn } from 'node:child_process';
33
36
  import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
34
- import { dirname, join } from 'node:path';
37
+ import { basename, dirname, join } from 'node:path';
38
+ import { zipSync } from 'fflate';
35
39
  // ---------------------------------------------------------------------------
36
40
  // Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
37
41
  // so an exported file can be imported back losslessly. Pure Node, no ctx.
@@ -124,7 +128,7 @@ export default {
124
128
  // ---------- agents-md 预设库 + 切换 ----------
125
129
  // DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
126
130
  // 本服务在 hub 内 agents-md/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
127
- // 新会话生效(当前会话不变,DSH 本身如此)。
131
+ // 下一轮对话生效:宿主 dsh-agent-instructions 每个 agent/pre-step 都会 stat 并重读该文件。
128
132
  // presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/agents-md。
129
133
  // v0.4:旧位置(插件目录 data/agents-md-presets/)在启动时搬入 hub——
130
134
  // 插件目录在 npm 安装下会被覆盖,放用户数据在那里本身就丢数据风险。
@@ -315,11 +319,15 @@ export default {
315
319
  const raw = await readDisabledTools();
316
320
  for (const [server, list] of Object.entries(raw))
317
321
  out[server] = list.filter((t) => t !== '*');
322
+ // 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
323
+ for (const [server, list] of Object.entries(await readKnownMcpTools())) {
324
+ out[server] = [...new Set([...(out[server] || []), ...list])].sort();
325
+ }
318
326
  let schemas = [];
319
327
  try {
320
328
  schemas = await tools.schemas();
321
329
  }
322
- catch { /* 无 live 工具 → 仅启停表 */ }
330
+ catch { /* 无 live 工具 → 仅停用表 + 缓存 */ }
323
331
  for (const s of schemas) {
324
332
  const p = toolKeyParts(String((s && s.name) || ''));
325
333
  if (!p)
@@ -365,12 +373,101 @@ export default {
365
373
  throw new Error(`技能 ${key} 应用失败: ${r.error}`);
366
374
  }
367
375
  },
376
+ // ── 服务器级 / 来源级启停(P5)────────────────────────────────────────────
377
+ // 为什么档案必须能改**上层**:工具级停用只在「服务器已经加载」时才有意义 ——
378
+ // 服务器没起来,它的工具根本不存在,勾选与否毫无区别。技能同理:来源关闭时,
379
+ // 技能级的 enable 会被 core 的 sourceEnabled 判定直接吞掉。
380
+ // 只报**补丁文件里真实存在**的行(level = project/global);loader 级条目改不了补丁。
381
+ mcpServerStates: async () => {
382
+ const r = await mcpmListView();
383
+ const out = [];
384
+ for (const row of ((r && r.rows) || [])) {
385
+ const level = String((row && row.level) || '');
386
+ if (level !== 'global' && level !== 'project')
387
+ continue;
388
+ out.push({
389
+ id: String(row.id),
390
+ serverName: String(row.serverName || row.id),
391
+ level,
392
+ disabled: !!row.disabled,
393
+ });
394
+ }
395
+ return out;
396
+ },
397
+ applyMcpServerSwitches: async (switches) => {
398
+ for (const s of switches) {
399
+ const r = await mcpmSetEnabled({ id: s.id, level: s.level, enabled: s.enabled });
400
+ if (r && r.ok === false)
401
+ throw new Error(`MCP 服务器 ${s.id} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
402
+ }
403
+ // 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
404
+ // 界面在应用完成后即可用,工具集合会在数秒内补齐。
405
+ },
406
+ // 场景备注(v0.8.1):备注写在 `dsh-plugin-tool-management-notes.json`(按 loader id),
407
+ // 场景档案按 serverName 存 —— 引擎负责映射与读写,与 mcpm-note 同一条写锁。
408
+ currentMcpNotes: async () => ({ ...(await readNotes()) }),
409
+ applyMcpNotes: async (entries) => {
410
+ await withWriteLock(async () => {
411
+ const map = Object.assign({}, await readNotes(true));
412
+ for (const e of entries) {
413
+ if (e.note)
414
+ map[e.id] = e.note;
415
+ else
416
+ delete map[e.id];
417
+ }
418
+ const p = await ensurePaths();
419
+ await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'), map);
420
+ notesCache = { at: Date.now(), value: map };
421
+ });
422
+ },
423
+ mcpIdOfServer: async (serverName) => {
424
+ const r = await mcpmListView();
425
+ for (const row of ((r && r.rows) || [])) {
426
+ if (String(row.serverName || row.id) === serverName)
427
+ return String(row.id);
428
+ }
429
+ return undefined;
430
+ },
431
+ skillSourceStates: async () => {
432
+ const r = await skillsService.ops['skill-state']({});
433
+ const roots = (r && r.data && r.data.roots) || [];
434
+ const out = [];
435
+ for (const root of roots) {
436
+ const key = String((root && root.key) || '');
437
+ if (!key)
438
+ continue;
439
+ // 默认来源(dsh / hub)不能停用(core 会拒绝)→ 不进切换集,避免无谓报错。
440
+ if (root.defaultSource === true)
441
+ continue;
442
+ out.push({ root: key, enabled: root.enabled === true });
443
+ }
444
+ return out;
445
+ },
446
+ applySkillSourceSwitches: async (switches) => {
447
+ for (const s of switches) {
448
+ 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)
451
+ throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
452
+ }
453
+ },
368
454
  sceneExists: async (name) => {
369
455
  const r = await rulesService.ops['rules-list']({});
370
456
  return !!(r && r.ok !== false && (r.scenes || []).some((s) => s.name === name));
371
457
  },
372
458
  // 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
373
459
  knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
460
+ // 人设开关读/写(进入模式启用档案勾选的人设;退出按快照停回)。
461
+ disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
462
+ applySubagentSwitches: async (switches) => {
463
+ const off = switches.filter((s) => !s.enabled).map((s) => s.name);
464
+ const on = switches.filter((s) => s.enabled).map((s) => s.name);
465
+ if (off.length)
466
+ await subagentService.enabledStore.setEnabled(off, false);
467
+ if (on.length)
468
+ await subagentService.enabledStore.setEnabled(on, true);
469
+ void subagentCatalog.refresh();
470
+ },
374
471
  // 记忆 id 全集(保存 memories 段时校验并报 stale)。
375
472
  knownMemoryIds: async () => {
376
473
  const r = await rulesService.ops['rules-list']({});
@@ -380,7 +477,8 @@ export default {
380
477
  // ---------- 轻量子智能体(设计 §3)----------
381
478
  // 人设 = $DSH_HOME/tool-management/agents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
382
479
  // sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
383
- const subagentService = createSubagentService(ctx, {});
480
+ // stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/agents-index.json。
481
+ const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
384
482
  const subagentSceneLists = async () => {
385
483
  const slice = await rulesService.readArchiveSlice();
386
484
  const r = await rulesService.ops['rules-list']({});
@@ -390,6 +488,153 @@ export default {
390
488
  .filter((s) => s.active)
391
489
  .map((s) => slice.archives[s.name]?.subagents ?? []);
392
490
  };
491
+ // ---------- 注入类段(P1 人设目录 + P2 MCP 状态)----------
492
+ // 两者是同一类缺陷的两端:**某个域的配置状态没有在场陈述**,模型只能靠用户口头提醒。
493
+ // - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_run 不会被触发。
494
+ // - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
495
+ // 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
496
+ // 两个段都只列「名字 + 摘要」,正文/工具清单绝不进上下文。
497
+ // 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts 的文件头。
498
+ const subagentCatalog = createSubagentCatalog({
499
+ list: () => subagentService.list(),
500
+ sceneLists: subagentSceneLists,
501
+ });
502
+ // ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
503
+ const mcpStateCatalog = createMcpStateCatalog({
504
+ rows: () => mcpmRowsWithNotes(false),
505
+ });
506
+ try {
507
+ 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');
514
+ }
515
+ catch (e) {
516
+ console.error('[dsh-plugin-tool-management] prompt section setup failed:', message(e));
517
+ }
518
+ // 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
519
+ // 避免在初始化中途就去读 MCP 补丁与工具 schema。
520
+ setTimeout(() => {
521
+ void subagentCatalog.warm();
522
+ void mcpStateCatalog.warm();
523
+ }, 0);
524
+ // 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
525
+ // 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
526
+ for (const opName of subagentService.writeOps) {
527
+ const original = subagentService.ops[opName];
528
+ if (typeof original !== 'function')
529
+ continue;
530
+ subagentService.ops[opName] = async (args) => {
531
+ const result = await original(args);
532
+ if (result && result.ok !== false)
533
+ void subagentCatalog.refresh();
534
+ return result;
535
+ };
536
+ }
537
+ /**
538
+ * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
539
+ * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
540
+ * 绑定存在 rules-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
541
+ */
542
+ async function rebindSubagentInArchives(from, to) {
543
+ try {
544
+ const slice = await rulesService.readArchiveSlice();
545
+ const archives = { ...(slice.archives || {}) };
546
+ let changed = 0;
547
+ for (const [scene, archive] of Object.entries(archives)) {
548
+ const list = archive.subagents;
549
+ if (!Array.isArray(list) || !list.includes(from))
550
+ continue;
551
+ archives[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
552
+ changed++;
553
+ }
554
+ if (changed)
555
+ await rulesService.patchIndex({ archives });
556
+ return changed;
557
+ }
558
+ catch {
559
+ return 0;
560
+ }
561
+ }
562
+ const baseSubagentUpdate = subagentService.ops['subagent-update'];
563
+ if (typeof baseSubagentUpdate === 'function') {
564
+ subagentService.ops['subagent-update'] = async (args) => {
565
+ const res = await baseSubagentUpdate(args);
566
+ if (res && res.ok !== false && res.renamedFrom)
567
+ void rebindSubagentInArchives(String(res.renamedFrom), String(res.name));
568
+ return res;
569
+ };
570
+ }
571
+ // 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
572
+ // 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
573
+ const baseSceneModeSet = archiveService.ops['scene-mode-set'];
574
+ if (typeof baseSceneModeSet === 'function') {
575
+ archiveService.ops['scene-mode-set'] = async (args) => {
576
+ // 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
577
+ // 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
578
+ // 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
579
+ try {
580
+ const slice = await rulesService.readArchiveSlice();
581
+ const current = slice.mode && slice.mode.scene;
582
+ if (current && args && 'scene' in (args || {})) {
583
+ const target = args.scene == null ? null : String(args.scene).trim() || null;
584
+ if (target !== current) {
585
+ const locked = await lockedSceneNames();
586
+ if (locked.includes(current)) {
587
+ return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
588
+ }
589
+ }
590
+ }
591
+ }
592
+ catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
593
+ const res = await baseSceneModeSet(args);
594
+ if (res && res.ok !== false) {
595
+ // 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
596
+ // 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
597
+ void subagentCatalog.refresh();
598
+ void mcpStateCatalog.refresh();
599
+ }
600
+ return res;
601
+ };
602
+ }
603
+ /**
604
+ * 模式进行中保存当前场景的档案时,联动人设开关:subagents 段新勾进来的名字**立即启用**,
605
+ * 并追加进快照的「模式期间自动启用」名单(退出时停回)。只启用、不停用 —— 用户手动
606
+ * 关掉的开关不该被一次档案保存悄悄改回去。
607
+ */
608
+ const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
609
+ if (typeof baseSceneArchiveSave === 'function') {
610
+ archiveService.ops['scene-archive-save'] = async (args) => {
611
+ const res = await baseSceneArchiveSave(args);
612
+ try {
613
+ 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
+ }
631
+ }
632
+ }
633
+ }
634
+ catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
635
+ return res;
636
+ };
637
+ }
393
638
  // HTTP 写操作门禁清单。skills/rules/档案引擎域由各自 service 导出的 writeOps 派生
394
639
  // (与其 ops 表同文件维护,新增写 op 改对应 service 即可);本文件内联域
395
640
  // (mcpm-* / skill-open / agentsmd-* / history-*)在此列举。
@@ -415,6 +660,8 @@ export default {
415
660
  // workspace-register 会新增一条宿主工作区登记,同样是写)
416
661
  'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
417
662
  'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
663
+ // 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
664
+ 'bundle-export',
418
665
  'history-archive-batch', 'history-workspace-register',
419
666
  ]);
420
667
  // 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
@@ -1355,6 +1602,34 @@ export default {
1355
1602
  }
1356
1603
  return out;
1357
1604
  }
1605
+ // ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
1606
+ // MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
1607
+ // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
1608
+ // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
1609
+ // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
1610
+ const KNOWN_MCP_TOOLS_FILE = 'dsh-plugin-tool-management-mcp-tools.json';
1611
+ let knownMcpToolsCache = null;
1612
+ async function readKnownMcpTools(force = false) {
1613
+ if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
1614
+ return knownMcpToolsCache.value;
1615
+ const p = await ensurePaths();
1616
+ const raw = await readJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE));
1617
+ const out = {};
1618
+ if (raw && typeof raw === 'object') {
1619
+ 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();
1623
+ }
1624
+ }
1625
+ knownMcpToolsCache = { at: Date.now(), value: out };
1626
+ return out;
1627
+ }
1628
+ async function writeKnownMcpTools(value) {
1629
+ const p = await ensurePaths();
1630
+ await writeJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE), value);
1631
+ knownMcpToolsCache = { at: Date.now(), value: value };
1632
+ }
1358
1633
  /** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
1359
1634
  function isToolDisabled(map, fullName) {
1360
1635
  if (!fullName.startsWith('mcp__'))
@@ -1585,8 +1860,77 @@ export default {
1585
1860
  toolsList.push({ name, description: '(已停用;描述暂不可用)', enabled: false, parameters: [] });
1586
1861
  }
1587
1862
  }
1863
+ // 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
1864
+ // 想给未运行服务器挑工具就全靠这份最后见过的名单(描述/参数不可用,按名字勾选)。
1865
+ for (const name of (await readKnownMcpTools())[serverName] || []) {
1866
+ if (listed.has(name))
1867
+ continue;
1868
+ toolsList.push({ name, description: '(服务器未运行;描述暂不可用)', enabled: !disabledHere.has(name), parameters: [] });
1869
+ listed.add(name);
1870
+ }
1588
1871
  return { ok: true, tools: toolsList };
1589
1872
  }
1873
+ /**
1874
+ * 临时启动服务器以枚举工具(v0.8.1):从未运行过的服务器在 schemas() 与「已知工具」
1875
+ * 缓存里都没有工具名,场景档案勾选器对它一无所知。这里**临时**把它启用(写补丁),
1876
+ * 轮询等它的工具注册(npx 冷启动要下载,上限 30s),拿到名单后就**恢复原启停状态**——
1877
+ * 用户环境的服务器开关不受影响。已有已知工具时直接返回现有清单,不做任何改动。
1878
+ */
1879
+ async function mcpmToolsRefresh(args) {
1880
+ const serverName = String((args && args.serverName) || '').trim();
1881
+ if (!serverName)
1882
+ return { ok: false, error: 'serverName 不能为空' };
1883
+ const existing = await mcpmTools({ serverName });
1884
+ if (existing && existing.ok === false)
1885
+ return existing;
1886
+ if (existing && (existing.tools || []).length)
1887
+ return existing;
1888
+ const list = await mcpmListView();
1889
+ const row = ((list && list.rows) || []).find((r) => String(r.serverName) === serverName);
1890
+ if (!row)
1891
+ return { ok: false, error: `未找到服务器: ${serverName}` };
1892
+ if (row.level !== 'global' && row.level !== 'project') {
1893
+ return { ok: false, error: `该服务器不驻留在补丁文件(level=${row.level}),无法临时启动;请先在 MCP 页启用它一次以记录工具` };
1894
+ }
1895
+ const wasDisabled = !!row.disabled;
1896
+ try {
1897
+ if (wasDisabled) {
1898
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: true });
1899
+ if (r && r.ok === false)
1900
+ return r;
1901
+ }
1902
+ const deadline = Date.now() + 30000;
1903
+ for (;;) {
1904
+ const t = await mcpmTools({ serverName });
1905
+ if (t && t.ok && (t.tools || []).length) {
1906
+ // 记进「已知工具」缓存:以后未运行时场景档案也能列出。
1907
+ const names = t.tools.map((x) => String(x.name)).filter(Boolean);
1908
+ if (names.length) {
1909
+ try {
1910
+ const cache = await readKnownMcpTools();
1911
+ const merged = [...new Set([...(cache[serverName] || []), ...names])].sort();
1912
+ cache[serverName] = merged;
1913
+ await writeKnownMcpTools(cache);
1914
+ }
1915
+ catch { /* 缓存写失败不影响本次返回 */ }
1916
+ }
1917
+ return t;
1918
+ }
1919
+ if (Date.now() >= deadline)
1920
+ break;
1921
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1922
+ }
1923
+ return { ok: false, error: `等待超时:${serverName} 在 30 秒内没有注册任何工具(服务器可能启动失败或连接过慢)` };
1924
+ }
1925
+ finally {
1926
+ if (wasDisabled) {
1927
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: false });
1928
+ if (!r || r.ok === false)
1929
+ console.warn('[dsh-plugin-tool-management] mcpm-tools-refresh: 恢复服务器停用状态失败', serverName);
1930
+ }
1931
+ void mcpStateCatalog.refresh();
1932
+ }
1933
+ }
1590
1934
  async function mcpmList() {
1591
1935
  const p = await ensurePaths();
1592
1936
  const rows = [];
@@ -1608,23 +1952,54 @@ export default {
1608
1952
  const toolCounts = {};
1609
1953
  try {
1610
1954
  const schemas = await tools.schemas();
1955
+ const knownCache = await readKnownMcpTools();
1956
+ const liveNames = {};
1611
1957
  const seen = new Set();
1612
1958
  for (const s of schemas) {
1613
1959
  const fullName = String(s && s.name || '');
1614
- seen.add(fullName);
1615
- const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1616
- if (m)
1617
- toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1618
- }
1619
- // Restricted tools may be absent from schemas(); they still belong to
1620
- // the per-server count so the UI does not show a phantom drop.
1621
- for (const fullName of disabledToolNames(await readDisabledTools())) {
1622
1960
  if (seen.has(fullName))
1623
1961
  continue;
1624
- const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1625
- if (m)
1626
- toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1962
+ seen.add(fullName);
1963
+ const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__(.+)$/);
1964
+ if (!m)
1965
+ continue;
1966
+ const list = liveNames[m[1]] || (liveNames[m[1]] = []);
1967
+ if (list.indexOf(m[2]) < 0)
1968
+ list.push(m[2]);
1969
+ }
1970
+ // 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
1971
+ // 也能显示最后一次见过的工具数,而不是永远 0。
1972
+ const union = {};
1973
+ const addTool = (server, tool) => {
1974
+ if (!server || !tool)
1975
+ return;
1976
+ const set = union[server] || (union[server] = new Set());
1977
+ set.add(tool);
1978
+ };
1979
+ for (const [server, list] of Object.entries(liveNames))
1980
+ for (const t of list)
1981
+ addTool(server, t);
1982
+ for (const [server, list] of Object.entries(await readDisabledTools()))
1983
+ for (const t of list)
1984
+ if (t !== '*')
1985
+ addTool(server, t);
1986
+ for (const [server, list] of Object.entries(knownCache))
1987
+ for (const t of list)
1988
+ addTool(server, t);
1989
+ for (const [server, set] of Object.entries(union))
1990
+ toolCounts[server] = set.size;
1991
+ // 回写「已知工具」:live 见到的名字并入缓存(有变化才写盘,读路径上的 best-effort)。
1992
+ let cacheChanged = false;
1993
+ for (const [server, list] of Object.entries(liveNames)) {
1994
+ 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;
1999
+ }
1627
2000
  }
2001
+ if (cacheChanged)
2002
+ void writeKnownMcpTools(knownCache).catch(() => { });
1628
2003
  }
1629
2004
  catch (e) { /* ignore */ }
1630
2005
  let live = [];
@@ -1710,7 +2085,10 @@ export default {
1710
2085
  }
1711
2086
  const before = content;
1712
2087
  content = appendBlock(content, buildInsertBlock(row));
1713
- if (args.enabled === false)
2088
+ // v0.8.5:新增服务器默认不启动(用户裁定,与技能 / 子智能体同口径);
2089
+ // 显式传 enabled: true 才创建即启动。
2090
+ const defaultDisabled = args.enabled !== true;
2091
+ if (defaultDisabled)
1714
2092
  content = appendBlock(content, buildDisableBlock(id, true));
1715
2093
  const guard = duplicateGuard(before, content);
1716
2094
  if (guard)
@@ -1721,7 +2099,7 @@ export default {
1721
2099
  catch (e) {
1722
2100
  return { ok: false, error: '写入补丁失败: ' + message(e) };
1723
2101
  }
1724
- return { ok: true, row: { ...row, level, disabled: args.enabled === false } };
2102
+ return { ok: true, row: { ...row, level, disabled: defaultDisabled } };
1725
2103
  });
1726
2104
  }
1727
2105
  async function mcpmEdit(args) {
@@ -2270,6 +2648,24 @@ export default {
2270
2648
  }
2271
2649
  const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
2272
2650
  const headers = registry.headers instanceof Map ? registry.headers : undefined;
2651
+ // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
2652
+ // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
2653
+ const dirExistsCache = new Map();
2654
+ async function directoryExists(p) {
2655
+ const k = workspacePathKey(p);
2656
+ const hit = dirExistsCache.get(k);
2657
+ if (hit !== undefined)
2658
+ return hit;
2659
+ let ok = false;
2660
+ try {
2661
+ ok = (await stat(p)).isDirectory();
2662
+ }
2663
+ catch {
2664
+ ok = false;
2665
+ }
2666
+ dirExistsCache.set(k, ok);
2667
+ return ok;
2668
+ }
2273
2669
  for (const it of items) {
2274
2670
  const sid = it.sessionId;
2275
2671
  let wid = owned.get(sid);
@@ -2299,17 +2695,21 @@ export default {
2299
2695
  const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
2300
2696
  const existing = groupsById.get(key);
2301
2697
  if (existing) {
2302
- // 同目录的多个会话:目录存在(sessionPaths 命中)即可重新登记。
2303
- if (canonical !== undefined)
2698
+ // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
2699
+ if (canonical !== undefined || (await directoryExists(path))) {
2304
2700
  existing.canRegister = true;
2701
+ existing.dirMissing = false;
2702
+ }
2305
2703
  continue;
2306
2704
  }
2705
+ const canRegister = canonical !== undefined || (await directoryExists(path));
2307
2706
  const view = {
2308
2707
  id: key,
2309
2708
  title: known?.title || workspaceBaseName(path) || path,
2310
2709
  kind: 'detached',
2311
2710
  registered: known !== undefined,
2312
- canRegister: canonical !== undefined,
2711
+ canRegister,
2712
+ dirMissing: !canRegister,
2313
2713
  path: known?.path || path,
2314
2714
  };
2315
2715
  groupsById.set(key, view);
@@ -2565,6 +2965,7 @@ export default {
2565
2965
  'mcpm-tool-enabled': mcpmToolEnabled,
2566
2966
  'skill-open': skillOpen,
2567
2967
  'mcpm-tools': mcpmTools,
2968
+ 'mcpm-tools-refresh': mcpmToolsRefresh,
2568
2969
  'mcpm-add': mcpmAdd,
2569
2970
  'mcpm-edit': mcpmEdit,
2570
2971
  'mcpm-set-enabled': mcpmSetEnabled,
@@ -2620,14 +3021,19 @@ export default {
2620
3021
  if (!name || seen.has(name))
2621
3022
  continue;
2622
3023
  seen.add(name);
3024
+ const live = !!(row.live && row.live.enabled);
2623
3025
  mcpServers.push({
2624
3026
  name,
2625
3027
  level: row.level || null,
2626
- live: !!(row.live && row.live.enabled),
3028
+ live,
2627
3029
  serverDisabled: !!row.disabled,
2628
3030
  // 停用表里的 ['*'] = 整台工具停用(勾选器据此预勾,避免把"全停"读成"全启用")。
2629
3031
  allToolsDisabled: (disabledRaw[name] || []).indexOf('*') >= 0,
2630
- toolCount: typeof row.toolCount === 'number' ? row.toolCount : null,
3032
+ // 未运行且没有任何已知工具 ⇒ null(客户端显示「工具数未知」并引导读取),
3033
+ // 不再给出误导性的 0。
3034
+ toolCount: live || Number(row.toolCount) > 0
3035
+ ? (typeof row.toolCount === 'number' ? row.toolCount : null)
3036
+ : null,
2631
3037
  });
2632
3038
  }
2633
3039
  return {
@@ -2684,13 +3090,16 @@ export default {
2684
3090
  'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
2685
3091
  ...(args && args.from ? { from: String(args.from) } : {}),
2686
3092
  ...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
3093
+ ...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
2687
3094
  }),
2688
3095
  // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
2689
3096
  // 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
2690
3097
  // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
2691
3098
  // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
2692
3099
  'agentsmd-update': async (args) => {
2693
- const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined);
3100
+ const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined,
3101
+ // 描述与正文同一次提交:省略(undefined)= 不动,空串 = 清空。
3102
+ args && typeof args.description === 'string' ? String(args.description) : undefined);
2694
3103
  if (!res || res.ok === false)
2695
3104
  return res;
2696
3105
  let renamed = {};
@@ -2716,7 +3125,7 @@ export default {
2716
3125
  }
2717
3126
  return agentsMdService.remove(id);
2718
3127
  },
2719
- 'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
3128
+ 'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
2720
3129
  // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
2721
3130
  // 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
2722
3131
  'agentsmd-trash-list': () => agentsMdService.trashList(),
@@ -2924,6 +3333,7 @@ export default {
2924
3333
  mayDelete: assessment.mayDelete,
2925
3334
  verifiedVersion: VERIFIED_HOST_VERSION,
2926
3335
  expectedPeerRange: EXPECTED_PEER_RANGE,
3336
+ minHostVersion: EXPECTED_MIN_HOST_VERSION,
2927
3337
  generatedAt: assessment.generatedAt,
2928
3338
  summary,
2929
3339
  };
@@ -3098,6 +3508,130 @@ export default {
3098
3508
  },
3099
3509
  // 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
3100
3510
  // 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
3511
+ // ---------- 通用导出(D14)----------
3512
+ // 技能 / 子智能体 / 提示词 / 记忆四个域的差异只有「从哪取哪些文件」,其余
3513
+ //(绝对路径校验、建目录、zip 打包、错误回报)完全相同 —— 所以只加**一个** op。
3514
+ // 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
3515
+ 'bundle-export': async (args) => {
3516
+ const kind = String((args && args.kind) || '');
3517
+ const KINDS = ['skills', 'subagents', 'presets', 'memories'];
3518
+ if (KINDS.indexOf(kind) < 0)
3519
+ return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
3520
+ const rawNames = (args && args.names) || [];
3521
+ const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
3522
+ if (!names.length)
3523
+ return { ok: false, error: '请至少选择一项' };
3524
+ const outDir = String((args && args.outDir) || '').trim();
3525
+ // 与 history-export 同一条校验:必须绝对路径(相对路径会在宿主进程的 cwd 下落盘,
3526
+ // 用户根本找不到文件)。
3527
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
3528
+ return { ok: false, error: '导出目录需为绝对路径' };
3529
+ // ① 收集 (zip 内路径, 绝对路径)。
3530
+ const entries = [];
3531
+ const missing = [];
3532
+ try {
3533
+ if (kind === 'subagents') {
3534
+ const dir = defaultPersonasDir();
3535
+ for (const name of names)
3536
+ entries.push({ zip: `${name}.md`, abs: join(dir, `${name}.md`) });
3537
+ }
3538
+ else if (kind === 'presets') {
3539
+ for (const id of names) {
3540
+ entries.push({ zip: `${id}/AGENTS.md`, abs: join(agentsMdPresetsDir, id, 'AGENTS.md') });
3541
+ // 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
3542
+ const metaAbs = join(agentsMdPresetsDir, id, 'meta.json');
3543
+ try {
3544
+ if ((await stat(metaAbs)).isFile())
3545
+ entries.push({ zip: `${id}/meta.json`, abs: metaAbs });
3546
+ }
3547
+ catch { /* 没写描述 */ }
3548
+ }
3549
+ }
3550
+ else if (kind === 'memories') {
3551
+ // 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/SKILL.md`)两种形态,
3552
+ // 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
3553
+ // 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
3554
+ const r = await rulesService.ops['rules-list']({});
3555
+ const plan = planMemoryExport(names, (r && r.rules) || []);
3556
+ missing.push(...plan.missing);
3557
+ for (const item of plan.entries) {
3558
+ if (item.kind === 'file') {
3559
+ entries.push({ zip: item.zip, abs: item.abs });
3560
+ continue;
3561
+ }
3562
+ // bundle:把目录内的文件全部打包(与技能分支同口径),单个目录读不了只丢它自己。
3563
+ try {
3564
+ for (const e of await readdir(item.abs, { withFileTypes: true })) {
3565
+ if (e.isFile())
3566
+ entries.push({ zip: `${item.zip}/${e.name}`, abs: join(item.abs, e.name) });
3567
+ }
3568
+ }
3569
+ catch {
3570
+ missing.push(item.id);
3571
+ }
3572
+ }
3573
+ }
3574
+ else {
3575
+ // 技能:先经 skill-detail 拿源文件路径(bundle 型是目录,单文件型是 .md)。
3576
+ for (const key of names) {
3577
+ const i = key.indexOf('/');
3578
+ if (i <= 0) {
3579
+ missing.push(key);
3580
+ continue;
3581
+ }
3582
+ const short = key.slice(i + 1);
3583
+ const r = await skillsService.ops['skill-detail']({ root: key.slice(0, i), name: short });
3584
+ const p = r && r.ok !== false && r.data ? String(r.data.path || '') : '';
3585
+ if (!p) {
3586
+ missing.push(key);
3587
+ continue;
3588
+ }
3589
+ let isDir = false;
3590
+ try {
3591
+ isDir = (await stat(p)).isDirectory();
3592
+ }
3593
+ catch {
3594
+ isDir = false;
3595
+ }
3596
+ if (isDir) {
3597
+ for (const e of await readdir(p, { withFileTypes: true })) {
3598
+ if (e.isFile())
3599
+ entries.push({ zip: `${short}/${e.name}`, abs: join(p, e.name) });
3600
+ }
3601
+ }
3602
+ else {
3603
+ entries.push({ zip: `${short}/${basename(p)}`, abs: p });
3604
+ }
3605
+ }
3606
+ }
3607
+ }
3608
+ catch (e) {
3609
+ return { ok: false, error: '收集待导出文件失败: ' + message(e) };
3610
+ }
3611
+ // ② 读盘 + 打包(读不到的项进 missing,不整体失败 —— 部分成功也有价值)。
3612
+ const bundle = {};
3613
+ for (const item of entries) {
3614
+ try {
3615
+ bundle[item.zip] = new Uint8Array(await readFile(item.abs));
3616
+ }
3617
+ catch {
3618
+ missing.push(item.zip);
3619
+ }
3620
+ }
3621
+ if (!Object.keys(bundle).length)
3622
+ return { ok: false, error: '没有可导出的文件(选中项都不存在?)' };
3623
+ let zipPath = '';
3624
+ try {
3625
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3626
+ zipPath = join(outDir, `dsh-tool-management-${kind}-${stamp}.zip`);
3627
+ await mkdir(outDir, { recursive: true });
3628
+ await writeFile(zipPath, Buffer.from(zipSync(bundle)));
3629
+ }
3630
+ catch (e) {
3631
+ return { ok: false, error: '写入导出文件失败: ' + message(e) };
3632
+ }
3633
+ return { ok: true, written: Object.keys(bundle).length, zipPath, missing };
3634
+ },
3101
3635
  'history-export': async (args) => {
3102
3636
  const rawIds = (args && args.sessionIds) || [];
3103
3637
  const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
@@ -3193,6 +3727,87 @@ export default {
3193
3727
  return { ok: true, retentionDays: days };
3194
3728
  },
3195
3729
  };
3730
+ // MCP 写操作成功后立即重算状态段(SWR:text() 同步返回缓存值,写后主动 refresh)。
3731
+ // 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
3732
+ // 不需要在界面层逐个补(漏一个就会出现「改了但模型看不到」的静默不一致)。
3733
+ for (const opName of ['mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all',
3734
+ 'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-tools-refresh']) {
3735
+ const original = handlers[opName];
3736
+ if (typeof original !== 'function')
3737
+ continue;
3738
+ handlers[opName] = async (args) => {
3739
+ const result = await original(args);
3740
+ if (result && result.ok !== false)
3741
+ void mcpStateCatalog.refresh();
3742
+ return result;
3743
+ };
3744
+ }
3745
+ // ---------- 场景锁定守卫(v0.8)----------
3746
+ // 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
3747
+ // 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
3748
+ // 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
3749
+ // service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
3750
+ // 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
3751
+ // 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
3752
+ async function lockedSceneNames() {
3753
+ const r = await rulesService.ops['rules-list']({});
3754
+ return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
3755
+ }
3756
+ function guardLockedOps(opNames, what) {
3757
+ for (const opName of opNames) {
3758
+ const original = handlers[opName];
3759
+ if (typeof original !== 'function')
3760
+ continue;
3761
+ handlers[opName] = async (args) => {
3762
+ const locked = await lockedSceneNames();
3763
+ if (locked.length)
3764
+ return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
3765
+ return original(args);
3766
+ };
3767
+ }
3768
+ }
3769
+ guardLockedOps([
3770
+ // MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
3771
+ 'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
3772
+ // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
3773
+ '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',
3775
+ 'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
3776
+ // 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
3777
+ 'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
3778
+ // 记忆:增删改 / 开关 / 导入 / 回收站 / 绑定。set-active 是场景启停,不在冻结范围。
3779
+ 'rules-create', 'rules-update', 'rules-remove', 'rules-toggle', 'rules-import', 'rules-restore', 'rules-trash-remove', 'rules-attach', 'rules-detach', 'rules-set-index',
3780
+ // 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
3781
+ 'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
3782
+ ], '修改');
3783
+ // 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
3784
+ for (const [opName, pickScene, what] of [
3785
+ ['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
3786
+ ['rules-remove-scene', (args) => (args && args.name) || '', '删除'],
3787
+ ]) {
3788
+ const original = handlers[opName];
3789
+ if (typeof original !== 'function')
3790
+ continue;
3791
+ handlers[opName] = async (args) => {
3792
+ const scene = String(pickScene(args) || '').trim();
3793
+ const locked = await lockedSceneNames();
3794
+ if (scene && locked.includes(scene))
3795
+ return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
3796
+ return original(args);
3797
+ };
3798
+ }
3799
+ // 各页列表响应带上 anyLocked:界面据此禁用写控件(读 op,附加字段不影响既有消费方)。
3800
+ async function annotateLocked(res) {
3801
+ if (res && res.ok !== false)
3802
+ res.anyLocked = (await lockedSceneNames()).length > 0;
3803
+ return res;
3804
+ }
3805
+ for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
3806
+ const original = handlers[opName];
3807
+ if (typeof original !== 'function')
3808
+ continue;
3809
+ handlers[opName] = async (args) => annotateLocked(await original(args));
3810
+ }
3196
3811
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
3197
3812
  const text = (value) => [{ type: 'text', text: value }];
3198
3813
  tools.register(defineTool({
@@ -3334,12 +3949,12 @@ export default {
3334
3949
  // 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
3335
3950
  // 是 complete)时文件内容不会进提示词。
3336
3951
  const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
3337
- return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)' + notice;
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;
3338
3953
  },
3339
3954
  }));
3340
3955
  tools.register(defineTool({
3341
3956
  name: 'agentsmd_apply',
3342
- description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md. Takes effect on the next session created; the current session is unchanged.',
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.',
3343
3958
  parameters: {
3344
3959
  id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
3345
3960
  },
@@ -3503,9 +4118,14 @@ export default {
3503
4118
  if (typeof ctx.on === 'function') {
3504
4119
  try {
3505
4120
  // MCP servers (de)register their tools as instances come and go; the
3506
- // visible-set restriction must track that.
4121
+ // visible-set restriction must track that — and so must the MCP state
4122
+ // section: 工具加载完成后若不重算,段会一直停留在「空/旧集合」的缓存值上
4123
+ //(用户切 MCP 后立即看系统提示词就是空段 —— 2026-09-16 实测)。
3507
4124
  ctx.effect(() => {
3508
- const stop = ctx.on('tools/change', () => scheduleToolRestrictions());
4125
+ const stop = ctx.on('tools/change', () => {
4126
+ scheduleToolRestrictions();
4127
+ void mcpStateCatalog.refresh();
4128
+ });
3509
4129
  return typeof stop === 'function' ? stop : () => { };
3510
4130
  }, 'dsh-plugin-tool-management: tools/change listener');
3511
4131
  }