dsh-plugin-tool-management 0.11.0 → 0.12.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.
@@ -71,6 +71,7 @@
71
71
  // 遥测同理(`noteToolUse` 自己吞异常):它绝不能影响工具本身。
72
72
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
73
73
  import { SessionSeq } from '@deepseek-ai/dsh-session';
74
+ import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
74
75
  /**
75
76
  * 权威域顺序(界面勾选、注入消息先后都按它)。
76
77
  * 场景和记忆排第一:它是"当前模式"的框架,先给框架再给内容。
@@ -415,6 +416,38 @@ function kindOfMessage(message) {
415
416
  const source = message?.source;
416
417
  return source && typeof source.kind === 'string' ? source.kind : undefined;
417
418
  }
419
+ // B4:关域拦截的**顺序自检**。官方那两条注入行与本插件同挂在这条瀑布上,拦截成立的剩余前提是
420
+ // "本插件排在它们上游"(本插件用 `{ prepend: true }` 把自己钉在钩子表最前,所以真正还依赖的
421
+ // 只剩一条:官方那两条仍用**默认 push** 方式注册)。这仍是非契约事实(见 pre-step 监听器里的
422
+ // 注释)。官方哪天改成 prepend 抢到更前面,症状就是「关了域、模型还是收到内容」,且**完全没有
423
+ // 报错**。这里做最轻的自检:关域生效期间连续 N 个真实 turn 一条都没剔到,就上报一次;
424
+ // 剔到过就收掉那条上报。如实写清两种可能(顺序变了 / 官方这一轮本来没内容),
425
+ // 不把它说成一定是事故。
426
+ const SUPPRESSION_SUSPECT_TURNS = 5;
427
+ function suppressionVerdict(active, dropped, counter) {
428
+ if (!active) {
429
+ counter.turns = 0;
430
+ counter.drops = 0;
431
+ clearRuntimeNote('official-suppression');
432
+ return;
433
+ }
434
+ counter.turns += 1;
435
+ counter.drops += dropped;
436
+ if (counter.drops > 0) {
437
+ counter.turns = 0;
438
+ clearRuntimeNote('official-suppression');
439
+ return;
440
+ }
441
+ if (counter.turns < SUPPRESSION_SUSPECT_TURNS)
442
+ return;
443
+ noteRuntime({
444
+ id: 'official-suppression',
445
+ label: '官方注入的关域拦截',
446
+ kind: 'read',
447
+ fallback: 'inform-only',
448
+ detail: `关掉的注入域已连续 ${counter.turns} 轮没有拦到任何官方消息(自检):可能是瀑布注册顺序变了导致拦截失效,也可能是官方那两条注入行这几轮本来就没有内容。可到「注入实况」对照模型实际收到的内容。`,
449
+ });
450
+ }
418
451
  /** 实况展示用的 kind 表:本插件的五条 + 官方两条。 */
419
452
  const LIVE_KINDS = new Map([
420
453
  ...PLUGIN_KINDS,
@@ -567,6 +600,18 @@ export function createContextInjector(deps) {
567
600
  const ctx = deps.ctx;
568
601
  if (!ctx || typeof ctx.on !== 'function')
569
602
  return { dispose: () => { }, live, noteToolUse };
603
+ /** B4 自检计数(每个注入器实例各自一份,随实例销毁而消失)。 */
604
+ const suppressionCounter = { turns: 0, drops: 0 };
605
+ // `{ prepend: true }` —— 位置**必须**钉在钩子表最前,理由见 `next()` 之后那段注释
606
+ // (关域拦截只在上游成立)。这里不靠"谁先注册":cordis 的 Loader 是**并发**挂载条目的
607
+ // (`await Promise.allSettled(config.map((options) => this.create(options)))`,
608
+ // cordis-plugin-loader/lib/index.js),每个官方包 apply 的完成先后由模块导入与 IO 决定,
609
+ // 逐次启动都可能不同;本插件的条目又是补丁层 `insert` 推到条目列表**末尾**的
610
+ // (dsh-app-boot/lib/index.js:顶层 insert 走 `data.push(...insert)`)。
611
+ // 2026-09-20 实测到那次翻转:关掉技能 / 提示词域后,官方注入照旧进上下文。
612
+ // `prepend` 由 cordis `EventsService.register()` 实现(`hooks[options.prepend ? 'unshift' : 'push']`),
613
+ // 于是不论挂载先后,本插件都排在官方那两条**默认 push** 注册的注入行之前 ——
614
+ // 顺序从"竞态"变成"约定"。
570
615
  const stop = ctx.on('agent/pre-step', async (payload, next) => {
571
616
  // 令牌门禁在**最前面**:没验过令牌时这一轮整个不放行,`next()` 都不必跑(后面那些注入
572
617
  // 本来就是给模型看的,模型这一步根本不会被调用)。宿主据此把 turn 收成 `blocked`。
@@ -603,17 +648,31 @@ export function createContextInjector(deps) {
603
648
  // 边界说明:这不是改官方包、也不是改宿主机制 —— pre-step 的 decision 本来就是每个插件
604
649
  // 都能改的那条缝(官方自己就在这里追加消息),我们只把它剔出这一步的批次。代价是官方
605
650
  // 插件每一步都会重新渲染并尝试注入(它的历史读的是会话事件,读不到被拦下的那条),
606
- // 模型侧不受影响。位置在本监听器 `next()` 之后:本插件在这条瀑布里位于官方之前
607
- // (自己的消息总落在批次末尾,实测),所以官方这一步追加的消息在这里看得见。
651
+ // 模型侧不受影响。
652
+ // **为什么 `prepend` 是必需的**:官方那两条注入行都在 `await next()` **之后**才往
653
+ // `decision.messages` 里追加(dsh-tool-skill 末尾 `[...decision.messages, catalog]`;
654
+ // dsh-agent-instructions `toSpliced(lastClaimedIndex + 1, 0, desired)`),而瀑布的
655
+ // `next()` 只做"取钩子表的下一个"。所以只有排在他们**上游**的监听器,其 `next()`
656
+ // 返回时才看得见这些追加 —— 本插件的过滤在 `next()` 之后,位置必须在上游。
608
657
  const suppressedKinds = officialKindsToSuppress(settings);
609
658
  let messages = messagesOf(decision);
610
659
  if (suppressedKinds.size > 0) {
660
+ let dropped = 0;
611
661
  const kept = messages.filter((message) => {
612
662
  const kind = kindOfMessage(message);
613
- return kind === undefined || !suppressedKinds.has(kind);
663
+ if (kind !== undefined && suppressedKinds.has(kind)) {
664
+ dropped += 1;
665
+ return false;
666
+ }
667
+ return true;
614
668
  });
615
669
  if (kept.length !== messages.length)
616
670
  messages = kept;
671
+ // B4 自检:只有"这一步真的在拦"时才有意义(令牌门禁 reject、空 turn 早退都在上面)。
672
+ suppressionVerdict(true, dropped, suppressionCounter);
673
+ }
674
+ else {
675
+ suppressionVerdict(false, 0, suppressionCounter);
617
676
  }
618
677
  const facts = await deps.factsFor(agent);
619
678
  const sections = selectInjections(domains, settings, facts, agent);
@@ -692,13 +751,17 @@ export function createContextInjector(deps) {
692
751
  log('context injection failed: ' + String((error && error.message) || error));
693
752
  return decision;
694
753
  }
695
- });
754
+ }, { prepend: true });
696
755
  return {
697
- dispose: () => { try {
698
- if (typeof stop === 'function')
699
- stop();
700
- }
701
- catch { /* ignore */ } },
756
+ dispose: () => {
757
+ try {
758
+ if (typeof stop === 'function')
759
+ stop();
760
+ }
761
+ catch { /* ignore */ }
762
+ // 注入通道没了,自检结论也失效(B4)——留着会让兼容页报一件不存在的事。
763
+ clearRuntimeNote('official-suppression');
764
+ },
702
765
  live,
703
766
  noteToolUse,
704
767
  };
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@
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 { symbols } from '@deepseek-ai/cordis';
13
14
  import { defineTool as hostDefineTool } from '@deepseek-ai/dsh-tools';
14
15
  import { createRequire } from 'node:module';
15
16
  import { randomUUID } from 'node:crypto';
@@ -26,6 +27,8 @@ import { createSubagentCatalog } from './subagents/catalog.js';
26
27
  import { createSkillCatalog } from './skills/catalog.js';
27
28
  import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
28
29
  import { isApprovalNever } from './approval-policy.js';
30
+ import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
31
+ import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
29
32
  import { reachNoticeForAgent } from './compat/preset-reach.js';
30
33
  import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
31
34
  import { createMcpManager } from './mcp/manager.js';
@@ -85,9 +88,22 @@ function serializeTurns(turns, format) {
85
88
  }
86
89
  return turns.map((t) => (t.role === 'user' ? '## User\n' : '### Assistant\n') + t.text).join('\n\n');
87
90
  }
91
+ /**
92
+ * `inject` 的服务名清单(单一来源):既声明给宿主,也是「挂载心跳」与 doctor 核对的内容。
93
+ *
94
+ * 为什么值得单列:任何一个名字被官方改名,本插件**根本不会 apply** —— 实测(2026-09-20,本仓库
95
+ * cordis)cordis 对未解析的 inject 既不抛错、不打日志、不发 warning,只把该 Fiber 停在非活动态。
96
+ * 那种情形下探测表 / 上报通道 / 兼容页**全都不存在**(失败域里一个信号都没有),本插件两次
97
+ * "宿主起不来"的历史事故也属于这一类。心跳文件是事后唯一的线索:doctor 读它 + 这份名单,
98
+ * 才能说清"插件这一轮没挂上,去核对这几个服务名"。
99
+ */
100
+ export const INJECT_SERVICES = [
101
+ 'timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions',
102
+ 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence',
103
+ ];
88
104
  export default {
89
105
  name: 'dsh-plugin-tool-management-host',
90
- inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
106
+ inject: [...INJECT_SERVICES],
91
107
  apply(ctx, config) {
92
108
  // 布局迁移必须**先于任何读盘**:hub 内的旧名(`agents-md/` → `prompts/` 等)与
93
109
  // `$DSH_HOME` 根下的插件侧车 / 日志 / patch 备份,都要在服务读它们之前搬到位。
@@ -130,6 +146,14 @@ export default {
130
146
  }
131
147
  catch (e) {
132
148
  console.error('[dsh-plugin-tool-management] skills provider setup failed:', message(e));
149
+ // B3:技能 provider 没挂上 → 技能页与 agent 技能目录静默失效。此前只落 console。
150
+ noteRuntime({
151
+ id: 'skills-provider',
152
+ label: '技能 provider 装配',
153
+ kind: 'read',
154
+ fallback: 'inform-only',
155
+ detail: '技能 provider 装配失败(' + message(e) + '):技能启用/停用与 agent 技能目录这一轮不生效。',
156
+ });
133
157
  }
134
158
  // ---------- 提示词预设库(hub/prompts/)+ 切换 ----------
135
159
  // DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
@@ -610,8 +634,43 @@ export default {
610
634
  }
611
635
  catch (e) {
612
636
  console.error('[dsh-plugin-tool-management] context injection setup failed:', message(e));
637
+ // B3:装配失败此前只落 console —— 用户界面上"注入块看起来正常、实际什么都没注入"。
638
+ noteRuntime({
639
+ id: 'context-injection',
640
+ label: '上下文注入通道',
641
+ kind: 'read',
642
+ fallback: 'inform-only',
643
+ detail: '注入通道装配失败(' + message(e) + '):五个注入域的内容这一轮不会被送进模型。',
644
+ });
613
645
  }
614
646
  void readInjectSettings().catch(() => { });
647
+ // B3 挂载心跳:apply 真跑到了这里,就记一笔「本插件在这一刻挂上了、声明的是这 12 个服务名」。
648
+ // 官方改名 inject 服务名时插件**不会 apply**,界面上「连插件都不见了」—— 那时唯一的线索
649
+ // 就是这份心跳没更新。doctor 读 hub/mount.json 并打印,配合官方日志即可定论(实测见
650
+ // INJECT_SERVICES 的注释)。失败只记日志,绝不因为它挡住启动。
651
+ void writeJsonFile(hubPath('mount.json'), {
652
+ at: Date.now(),
653
+ iso: new Date().toISOString(),
654
+ version: PKG_VERSION,
655
+ injects: [...INJECT_SERVICES],
656
+ }).catch((e) => { ctx.logger?.warn?.('mount heartbeat write failed: ' + message(e)); });
657
+ // B3 符号断言:probe.ts 的 unwrap 用硬编码的 `Symbol.for('cordis.original')`,与 cordis
658
+ // 导出的 `symbols.original` 必须是同一个符号。不是的话,探针会把代理当原始对象、身份判定失真。
659
+ try {
660
+ if (Symbol.for('cordis.original') === symbols.original) {
661
+ clearRuntimeNote('cordis-original-symbol');
662
+ }
663
+ else {
664
+ noteRuntime({
665
+ id: 'cordis-original-symbol',
666
+ label: 'cordis 原始对象符号',
667
+ kind: 'read',
668
+ fallback: 'inform-only',
669
+ detail: 'cordis 导出的 symbols.original 与 Symbol.for("cordis.original") 不是同一个符号:能力探测可能把代理当原始对象,身份判定会失真。',
670
+ });
671
+ }
672
+ }
673
+ catch (e) { /* 拿不到 symbols 就跳过(探针自己也有一条退路) */ }
615
674
  // 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
616
675
  // 避免在初始化中途就去读 MCP 补丁与工具 schema。
617
676
  setTimeout(() => {
@@ -1113,8 +1172,17 @@ export default {
1113
1172
  */
1114
1173
  async function writePatch(abs, content) {
1115
1174
  const policy = await sandboxPolicy.resolve({ mode: 'danger-full-access' });
1175
+ let previous = '';
1176
+ try {
1177
+ previous = await readPatch(abs);
1178
+ }
1179
+ catch (e) { /* 读不到就没有基线,校验按「没有基线」判 */ }
1180
+ // 写前校验(非对称策略,理由与三条分支见 compat/patch-dialect.ts 的文件头):
1181
+ // 只有「我们这次把产物改成了官方解析不了的样子」才拦;复刻过期 / 依赖缺失一律放行 + 上报。
1182
+ const verdict = await checkPatchWrite(previous, content);
1183
+ if (!verdict.allow)
1184
+ throw new Error(verdict.error);
1116
1185
  try {
1117
- const previous = await readPatch(abs);
1118
1186
  if (previous && previous !== content)
1119
1187
  await backupPatchFile(abs, previous);
1120
1188
  }
@@ -1134,6 +1202,21 @@ export default {
1134
1202
  throw e;
1135
1203
  }
1136
1204
  }
1205
+ /**
1206
+ * 写入回执的 warning 附着点:把本次请求里「补丁校验没做成」的结论挂在结果上。
1207
+ *
1208
+ * 口径与既有回执一致 —— `warning` 是**一个字符串**(MCP 域已有同款字段,界面按
1209
+ * `mcp.msg.warn` 渲染)。原有 warning 保留,本插件的追加在后面,用「;」分隔。
1210
+ */
1211
+ function withPatchWarnings(result) {
1212
+ const warnings = takePatchGuardWarnings();
1213
+ if (warnings.length === 0 || !result || typeof result !== 'object')
1214
+ return result;
1215
+ const extra = warnings.join(';');
1216
+ if (typeof result.warning === 'string' && result.warning !== '')
1217
+ return { ...result, warning: result.warning + ';' + extra };
1218
+ return { ...result, warning: extra };
1219
+ }
1137
1220
  /**
1138
1221
  * 找到本插件 loader 行所在的补丁文件。返回 0 / 1 / 多份,多份时调用方必须拒绝自动改:
1139
1222
  * 同一条 loader 行出现在两份补丁里会让宿主起不来(重复 id),与 duplicateGuard 同一口径。
@@ -1465,6 +1548,11 @@ export default {
1465
1548
  listPatchBackups,
1466
1549
  cleanPatchBackups,
1467
1550
  getContextInjectorLive: () => contextInjectorLive,
1551
+ // 功能总览(B6)的三层合成:令牌实况与兼容页「访问令牌」同源;场景锁定与写门禁同源;
1552
+ // 停用工具数读 mcp 的 TTL 缓存(无 I/O)。
1553
+ readTokenState: async () => ({ active: TOKEN !== '', accepted: TOKEN !== '' && acceptedThisBoot() }),
1554
+ lockedSceneNames: () => lockedSceneNames(),
1555
+ disabledToolCount: () => mcp.disabledToolCount(),
1468
1556
  message,
1469
1557
  compatLog,
1470
1558
  }),
@@ -2041,6 +2129,31 @@ export default {
2041
2129
  mcp.warmUp().catch(() => { });
2042
2130
  }
2043
2131
  catch (e) { /* ignore */ }
2132
+ // 可见性半边(B1):官方 `tools.restrict()` 要求 **agent scope**,所以停用的工具要
2133
+ // 逐个 agent 应用 —— 与技能侧同一套生命周期写法(`agent/created` / `agent/disposed`
2134
+ // + 启动期扫一遍 `agents.list()`,见 skills/service.ts)。
2135
+ if (typeof ctx.on === 'function') {
2136
+ try {
2137
+ ctx.effect(() => {
2138
+ const stop = ctx.on('agent/created', (payload) => {
2139
+ mcp.attachAgent(payload && payload.agent);
2140
+ });
2141
+ return typeof stop === 'function' ? stop : () => { };
2142
+ }, 'dsh-plugin-tool-management: mcp visibility (agent created)');
2143
+ ctx.effect(() => {
2144
+ const stop = ctx.on('agent/disposed', (payload) => {
2145
+ mcp.detachAgent(payload && payload.agent);
2146
+ });
2147
+ return typeof stop === 'function' ? stop : () => { };
2148
+ }, 'dsh-plugin-tool-management: mcp visibility (agent disposed)');
2149
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined;
2150
+ if (agents && typeof agents.list === 'function') {
2151
+ for (const agent of agents.list())
2152
+ mcp.attachAgent(agent);
2153
+ }
2154
+ }
2155
+ catch (e) { /* agents 服务不可用 → 仅靠事件(与技能侧同一退路) */ }
2156
+ }
2044
2157
  if (typeof tools.guard === 'function') {
2045
2158
  ctx.effect(() => tools.guard((exec) => {
2046
2159
  try {
@@ -2245,7 +2358,7 @@ export default {
2245
2358
  // 设置 / 关闭令牌:同样就地处理(理由见 tokenConfigure 的注释 —— 也正因为不进
2246
2359
  // handlers,模型侧没有任何工具能间接关掉它)。
2247
2360
  if (op === 'token-configure') {
2248
- res.end(JSON.stringify(await tokenConfigure(payload.args || {}, tokenAccepted)));
2361
+ res.end(JSON.stringify(withPatchWarnings(await tokenConfigure(payload.args || {}, tokenAccepted))));
2249
2362
  return;
2250
2363
  }
2251
2364
  // 「清除令牌」的配套:把「本次启动已验过」的闩重新挂上。没有它,解锁一次之后
@@ -2264,7 +2377,7 @@ export default {
2264
2377
  return;
2265
2378
  }
2266
2379
  const result = await fn(payload.args || {});
2267
- res.end(JSON.stringify(result === undefined ? { ok: true } : result));
2380
+ res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
2268
2381
  }
2269
2382
  catch (e) {
2270
2383
  res.end(JSON.stringify({ ok: false, error: message(e) }));
@@ -17,6 +17,7 @@
17
17
  // **正向依赖**(本文件调用外部):补丁行解析用 ./patch-yaml.ts、打码判据用 ./secret-guard.ts,
18
18
  // 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
19
19
  // 由 deps 显式传入。
20
+ import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
20
21
  import { MCP_CLIENT_MODULE } from '../host-names.js';
21
22
  import { hubPath } from '../hub.js';
22
23
  import { planOverrideCompaction } from './override-blocks.js';
@@ -445,11 +446,82 @@ export function createMcpManager(deps) {
445
446
  return { ok: true, serverName, disabled: map[serverName] || [] };
446
447
  });
447
448
  }
448
- // Visibility seam: keep one active restriction, refreshed whenever the tool
449
- // set or the disabled set changes. `restrict` fails on unknown names, so the
450
- // deny list is always intersected with the currently registered tools.
451
- let restrictDisposer = null;
449
+ // Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
450
+ // the tool set or the disabled set changes. Three facts decide this design(都读过官方源码
451
+ // dsh-tools 0.1.5-rc.2):
452
+ // · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
453
+ // context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
454
+ // · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
455
+ // (`layers = new ScopedLayers(…, () => this.ctx.emit("tools/change"))`),而重放正挂在
456
+ // `tools/change` 上 —— 名单没变还重放就是自激;
457
+ // · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
458
+ // 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
452
459
  let restrictTimer = null;
460
+ /** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
461
+ let appliedNamesKey = null;
462
+ /** 当前期望的 deny 名单(agent 晚到 / 重放时用它)。 */
463
+ let desiredNames = [];
464
+ /** agent → 撤掉这层限制的 disposer。 */
465
+ const agentRestrictions = new Map();
466
+ /** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
467
+ const seenAgents = new Set();
468
+ function scopedToolsOf(agent) {
469
+ const scoped = agent && agent.ctx && agent.ctx.tools;
470
+ return scoped && typeof scoped === 'object' ? scoped : undefined;
471
+ }
472
+ /** 把当前名单装到某个 agent scope 上(空名单 = 没有要摘的工具,不碰它)。 */
473
+ function restrictAgent(agent) {
474
+ if (desiredNames.length === 0)
475
+ return;
476
+ const scoped = scopedToolsOf(agent);
477
+ if (!scoped || typeof scoped.restrict !== 'function') {
478
+ // agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
479
+ // 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
480
+ noteRuntime({
481
+ id: 'mcp-tool-visibility',
482
+ label: '停用工具的可见性',
483
+ kind: 'write',
484
+ fallback: 'inform-only',
485
+ detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
486
+ });
487
+ return;
488
+ }
489
+ try {
490
+ const dispose = scoped.restrict({ deny: desiredNames });
491
+ agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
492
+ clearRuntimeNote('mcp-tool-visibility');
493
+ }
494
+ catch (e) {
495
+ // 这个名字在这个 scope 里认不出 / scope 已经收了:可见性半边没生效。执行侧的 guard
496
+ // 仍然拦住调用,所以功能不缺 —— 但必须如实上报,不能假装成功(此前正是静默吞掉)。
497
+ noteRuntime({
498
+ id: 'mcp-tool-visibility',
499
+ label: '停用工具的可见性',
500
+ kind: 'write',
501
+ fallback: 'inform-only',
502
+ detail: '停用的 MCP 工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
503
+ });
504
+ }
505
+ }
506
+ /** agent 上线(`agent/created` 或启动期的 agent 列表):登记并立刻应用当前名单。 */
507
+ function attachAgent(agent) {
508
+ if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
509
+ return;
510
+ seenAgents.add(agent);
511
+ restrictAgent(agent);
512
+ }
513
+ /** agent 下线(`agent/disposed`):先撤限制再销登记(撤限制会把 disposer 摘掉)。 */
514
+ function detachAgent(agent) {
515
+ seenAgents.delete(agent);
516
+ const dispose = agentRestrictions.get(agent);
517
+ agentRestrictions.delete(agent);
518
+ if (dispose) {
519
+ try {
520
+ dispose();
521
+ }
522
+ catch (e) { /* ignore */ }
523
+ }
524
+ }
453
525
  async function applyToolRestrictions() {
454
526
  if (typeof tools.restrict !== 'function')
455
527
  return;
@@ -474,19 +546,23 @@ export function createMcpManager(deps) {
474
546
  wanted.push(fullName);
475
547
  }
476
548
  const names = wanted.filter((name) => registered.has(name));
477
- if (restrictDisposer) {
549
+ const key = names.join('\u0000');
550
+ if (key === appliedNamesKey)
551
+ return;
552
+ appliedNamesKey = key;
553
+ desiredNames = names;
554
+ // 每次重放先撤旧限制:名单变短(重新启用某个工具)时,只有撤掉这层限制它才会重新可见。
555
+ for (const [agent, dispose] of agentRestrictions) {
556
+ agentRestrictions.delete(agent);
478
557
  try {
479
- restrictDisposer();
558
+ dispose();
480
559
  }
481
560
  catch (e) { /* ignore */ }
482
- restrictDisposer = null;
483
561
  }
484
562
  if (!names.length)
485
563
  return;
486
- try {
487
- restrictDisposer = tools.restrict({ deny: names });
488
- }
489
- catch (e) { /* registry race: the next tools/change event retries */ }
564
+ for (const agent of seenAgents)
565
+ restrictAgent(agent);
490
566
  }
491
567
  function scheduleToolRestrictions() {
492
568
  if (restrictTimer)
@@ -1637,13 +1713,15 @@ export function createMcpManager(deps) {
1637
1713
  clearTimeout(restrictTimer);
1638
1714
  restrictTimer = null;
1639
1715
  }
1640
- if (restrictDisposer) {
1716
+ // 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
1717
+ for (const [, release] of agentRestrictions) {
1641
1718
  try {
1642
- restrictDisposer();
1719
+ release();
1643
1720
  }
1644
1721
  catch (e) { /* ignore */ }
1645
- restrictDisposer = null;
1646
1722
  }
1723
+ agentRestrictions.clear();
1724
+ seenAgents.clear();
1647
1725
  }
1648
1726
  return {
1649
1727
  stateCatalog: mcpStateCatalog,
@@ -1674,8 +1752,18 @@ export function createMcpManager(deps) {
1674
1752
  mcpmRowsWithNotes,
1675
1753
  readPluginSettings,
1676
1754
  isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),
1755
+ /** 停用表里的工具条数(读 TTL 缓存;功能总览用)。 */
1756
+ disabledToolCount: () => {
1757
+ const map = disabledToolsCache ? disabledToolsCache.value : {};
1758
+ let n = 0;
1759
+ for (const list of Object.values(map))
1760
+ n += Array.isArray(list) ? list.length : 0;
1761
+ return n;
1762
+ },
1677
1763
  warmUp,
1678
1764
  scheduleToolRestrictions,
1765
+ attachAgent,
1766
+ detachAgent,
1679
1767
  dispose,
1680
1768
  };
1681
1769
  }
package/lib/ops/compat.js CHANGED
@@ -6,8 +6,10 @@
6
6
  //
7
7
  // 边界纪律:本文件只负责「op 的实现」,不负责 ops 表之外的接线(MCP 写后刷新、场景锁定
8
8
  // 守卫仍作用在组装后的整张表上,见 index.ts)。
9
- import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize, } from '../compat/probe.js';
9
+ import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, routeFor, summarize, } from '../compat/probe.js';
10
+ import { INJECT_DOMAIN_KEYS } from '../context-inject.js';
10
11
  import { assessPresetReach } from '../compat/preset-reach.js';
12
+ import { runtimeNotes } from '../compat/runtime-notes.js';
11
13
  import { pluginLog } from '../skills/service.js';
12
14
  export function buildCompatOps(deps) {
13
15
  return {
@@ -29,13 +31,30 @@ export function buildCompatOps(deps) {
29
31
  };
30
32
  }
31
33
  const summary = summarize(assessment);
34
+ // 运行时降级(A1 的补丁校验、A2 的投影缓存适配、B3 的装配层…)统一在这里并进
35
+ // findings/degraded:它们不是宿主能力,但必须各占一行 —— 否则用户只能在事故之后
36
+ // 才知道某道保险没生效。上报口径见 compat/runtime-notes.ts。
37
+ const extraFindings = runtimeNotes().map((note) => ({
38
+ id: note.id,
39
+ label: note.label,
40
+ kind: note.kind,
41
+ owner: 'plugin',
42
+ fallback: note.fallback,
43
+ state: 'not-available',
44
+ missing: [],
45
+ detail: note.detail,
46
+ }));
47
+ const findings = [...assessment.findings, ...extraFindings];
48
+ const degraded = [...assessment.degraded, ...extraFindings];
49
+ const merged = { ...assessment, findings, degraded };
50
+ const summaryMerged = extraFindings.length === 0 ? summary : summarize(merged);
32
51
  // 每次刷新都留一条结构化日志:升级当天就能在日志里看到降级发生。
33
52
  if (force || !deps.compatLog.version || deps.compatLog.version !== assessment.identity.version) {
34
53
  deps.compatLog.version = assessment.identity.version;
35
54
  void pluginLog()('compat/probe', {
36
55
  hostVersion: assessment.identity.version,
37
- summary,
38
- degraded: assessment.degraded.map((item) => ({ id: item.id, state: item.state, detail: item.detail })),
56
+ summary: summaryMerged,
57
+ degraded: degraded.map((item) => ({ id: item.id, state: item.state, detail: item.detail })),
39
58
  blockers: assessment.identity.blockers,
40
59
  sameAsHost: assessment.identity.sameAsHost,
41
60
  }).catch(() => { });
@@ -45,15 +64,15 @@ export function buildCompatOps(deps) {
45
64
  host: { version: assessment.identity.version, modules: assessment.identity.modules },
46
65
  sameAsHost: assessment.identity.sameAsHost,
47
66
  unverified: assessment.identity.unverified,
48
- findings: assessment.findings,
49
- degraded: assessment.degraded,
67
+ findings,
68
+ degraded,
50
69
  blockers: assessment.identity.blockers,
51
70
  mayDelete: assessment.mayDelete,
52
71
  verifiedVersion: VERIFIED_HOST_VERSION,
53
72
  expectedPeerRange: EXPECTED_PEER_RANGE,
54
73
  minHostVersion: EXPECTED_MIN_HOST_VERSION,
55
74
  generatedAt: assessment.generatedAt,
56
- summary,
75
+ summary: summaryMerged,
57
76
  };
58
77
  }
59
78
  catch (e) {
@@ -91,6 +110,77 @@ export function buildCompatOps(deps) {
91
110
  },
92
111
  // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
93
112
  'inject-settings': (args) => deps.injectSettingsOp(args),
113
+ // 功能总览(B6):**按功能点**回答"现在每一项到底能不能用",三层合成 ——
114
+ // ① 装配层(listener / provider / 适配是否真的挂上,来自运行时上报通道)
115
+ // ② 宿主能力层(compat 探测的路由判定)
116
+ // ③ 用户配置层(开关 / 令牌 / 场景锁定)
117
+ // 数据全部来自已有的缓存状态与运行时上报,**不新增宿主探测**(避免哨兵真调一类的开销)。
118
+ // 行内 `tab` 供界面跳转到对应页签;`state` 取值见下方约定。
119
+ 'feature-overview': async () => {
120
+ try {
121
+ const registry = deps.getSessionsRegistry();
122
+ const assessment = registry && typeof registry.capabilities === 'function' ? registry.capabilities() : undefined;
123
+ const notes = new Map(runtimeNotes().map((note) => [note.id, note]));
124
+ const settings = await deps.readInjectSettings();
125
+ const domainsOn = INJECT_DOMAIN_KEYS.filter((key) => settings.domains[key] !== false);
126
+ const token = await deps.readTokenState();
127
+ const lockedScenes = await deps.lockedSceneNames();
128
+ const disabledTools = deps.disabledToolCount();
129
+ const routeOf = (operation) => assessment === undefined ? undefined : routeFor(assessment, operation);
130
+ /** 一行一个功能点:状态三层合成,`detail` 写明依据。 */
131
+ const rows = [];
132
+ const push = (key, label, tab, state, detail) => rows.push({ key, label, tab, state, detail });
133
+ // 前置项:插件挂载(这一行本身说明 12 个 inject 都解析了 —— 否则 apply 根本不会跑);
134
+ // 启动期那段 `!!js` 表达式若抛错,插件同样不会挂上(它的 try/catch 就是为了不抛)。
135
+ const mountNote = notes.get('cordis-original-symbol');
136
+ push('mount', '插件挂载', 'compat', mountNote === undefined ? 'ok' : 'degraded', mountNote === undefined ? '12 个 inject 服务全部解析,插件已挂载(详见 doctor 的挂载心跳)' : mountNote.detail);
137
+ // 装配层:有上报就是降级,附上报里的原因;没有就是正常。
138
+ const assemblyRows = [
139
+ ['patch-write-guard', '宿主配置写入(补丁校验)', 'mcp'],
140
+ ['context-injection', '上下文注入通道', 'compat'],
141
+ ['official-suppression', '官方注入的关域拦截', 'compat'],
142
+ ['skills-provider', '技能 provider 装配', 'skills'],
143
+ ['projection-cache-adapter', '投影缓存删除屏障', 'sessions'],
144
+ ['mcp-tool-visibility', '停用工具的可见性', 'mcp'],
145
+ ];
146
+ for (const [noteId, label, tab] of assemblyRows) {
147
+ const note = notes.get(noteId);
148
+ push(noteId, label, tab, note === undefined ? 'ok' : 'degraded', note === undefined ? '装配正常,无降级上报' : note.detail);
149
+ }
150
+ // 宿主能力层:删除 / 归档 / 恢复 / 批量各自由路由判定回答(与服务端执行同源)。
151
+ const deleteRoute = routeOf('delete');
152
+ push('session-delete', '会话删除', 'sessions', deleteRoute === undefined ? 'unknown' : deleteRoute.via === 'none' ? 'unavailable' : 'ok', deleteRoute === undefined ? '能力探测不可用(归档服务未挂载)'
153
+ : deleteRoute.via === 'none' ? '宿主缺少该路径所需能力:' + deleteRoute.refusals.map((item) => item.label).join('、')
154
+ : `可用(路径:${deleteRoute.via})`);
155
+ for (const [operation, label] of [['archive', '归档'], ['unarchive', '恢复'], ['batch', '批量操作'], ['list', '历史列表']]) {
156
+ const decision = routeOf(operation);
157
+ push('session-' + operation, `会话${label}`, 'sessions', decision === undefined ? 'unknown' : decision.via === 'none' ? 'unavailable' : 'ok', decision === undefined ? '能力探测不可用(归档服务未挂载)'
158
+ : decision.via === 'none' ? '宿主缺少该路径所需能力:' + decision.refusals.map((item) => item.label).join('、')
159
+ : `可用(路径:${decision.via})`);
160
+ }
161
+ // 用户配置层:注入域 / 令牌 / 场景锁定 / 停用工具数。
162
+ push('injection-domains', '注入域开关', 'compat', domainsOn.length === 0 ? 'disabled' : 'ok', domainsOn.length === 0 ? '五个注入域全部关闭(用户设置)' : `${domainsOn.length}/5 个域开启:${domainsOn.join('、')}`);
163
+ push('token', '访问令牌', 'compat', !token.active ? 'disabled' : token.accepted ? 'ok' : 'locked', !token.active ? '未启用(宿主没配令牌,或令牌功能被关掉)'
164
+ : token.accepted ? '已生效且本次启动已通过验证' : '已生效,本次启动尚未验证:写操作与对话会被拦住,到本页下方填写令牌');
165
+ push('scene-lock', '场景锁定', 'scenes', lockedScenes.length === 0 ? 'ok' : 'locked', lockedScenes.length === 0 ? '无锁定场景' : `锁定中:${lockedScenes.join('、')}(写门禁按锁定场景生效)`);
166
+ push('mcp-tools', 'MCP 工具停用', 'mcp', disabledTools === 0 ? 'ok' : 'partial', disabledTools === 0 ? '没有停用的工具' : `${disabledTools} 个工具处于停用态(执行拦截 + 可见性摘除)`);
167
+ push('native-delete', '宿主原生删除入口', 'compat', notes.has('workspace.delete-native') ? 'partial' : 'ok', notes.get('workspace.delete-native')?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)');
168
+ // 身份 / 版本:与 compat-status 同一份数据。
169
+ const identity = assessment?.identity;
170
+ const identityIssue = identity !== undefined && (identity.blockers.length > 0 || Object.values(identity.sameAsHost).some((same) => same === false));
171
+ push('host-identity', '宿主身份与模块', 'compat', assessment === undefined ? 'unknown' : identityIssue ? 'degraded' : 'ok', assessment === undefined ? '能力探测不可用(归档服务未挂载)'
172
+ : identityIssue ? '存在阻塞项:' + (identity?.blockers ?? []).join(';')
173
+ : `模块与宿主同源(宿主 DSH ${identity?.version ?? '?'})`);
174
+ // 无独立装配点的域:没有上报就是正常(如实说明依据是"没有降级上报")。
175
+ for (const [key, label, tab] of [['memory', '记忆', 'memory'], ['prompts', '提示词', 'prompts'], ['subagents', '子智能体', 'subagents'], ['scenes', '场景档案', 'scenes']]) {
176
+ push(key, label, tab, 'ok', '无降级上报(装配失败会出现在这里)');
177
+ }
178
+ return { ok: true, rows, generatedAt: Date.now() };
179
+ }
180
+ catch (e) {
181
+ return { ok: false, error: deps.message(e) };
182
+ }
183
+ },
94
184
  // patch 备份(改宿主配置前的整份副本):列清单只读,清理按写门禁。
95
185
  // 每份备份里都是**明文**凭据,所以给一个显式出口让人能删掉多余的副本(理由见
96
186
  // cleanPatchBackups 的注释)。清单不带文件内容,只给名字 / 层级 / 时间 / 大小。