dsh-plugin-tool-management 0.11.0 → 0.12.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.
@@ -30,7 +30,7 @@
30
30
  * leave the rest working.
31
31
  */
32
32
  import { createRequire } from 'node:module';
33
- import { readFileSync, realpathSync } from 'node:fs';
33
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
34
34
  import { dirname, join } from 'node:path';
35
35
  /** Packages whose physical module identity matters to this plugin. */
36
36
  export const IDENTITY_PACKAGES = [
@@ -402,6 +402,31 @@ const CAPABILITY_SPECS = [
402
402
  fallback: 'disable-destructive',
403
403
  methods: ['write', 'put', 'requireTable'],
404
404
  },
405
+ {
406
+ // B2:此前它只是 bridge 运行时那句拒绝里的**临时 id**(`acquireCacheGuard` 里现场拼的),
407
+ // 于是客户端压根不知道它 —— 宿主表不可删时,"路由说可用、点下去必拒"(V13)。提升为
408
+ // 能力表的正式条目后,路由判定与运行时前提同一份依据。
409
+ id: 'projection.table-delete',
410
+ label: '投影缓存行删除',
411
+ kind: 'delete',
412
+ owner: 'projectionCache',
413
+ fallback: 'disable-destructive',
414
+ // 探测内容就是运行时那句硬前提:`requireTable().delete` 在不在。
415
+ probe: (target) => {
416
+ if (typeof target?.requireTable !== 'function')
417
+ return '宿主投影缓存缺少 requireTable';
418
+ let table;
419
+ try {
420
+ table = target.requireTable();
421
+ }
422
+ catch (error) {
423
+ return `宿主投影缓存 requireTable() 抛错:${String(error)}`;
424
+ }
425
+ if (typeof table?.delete !== 'function')
426
+ return '宿主投影缓存存储不支持安全删除(table.delete 缺失)';
427
+ return undefined;
428
+ },
429
+ },
405
430
  {
406
431
  id: 'projection.delete-native',
407
432
  label: '投影缓存删除屏障',
@@ -455,6 +480,10 @@ export const OPERATION_ROUTES = {
455
480
  adapter: [
456
481
  'workspace.enqueue', 'workspace.set-state', 'workspace.index-header',
457
482
  'sessions.detach-live', 'sessions.cold-announce', 'projection.write',
483
+ // B2:运行时硬前提(`table.delete`)也算一条路由要求 —— 不算进来的话,宿主表不可删时
484
+ // 路由说可用、点下去必拒。batch 的 adapter 路由**有意**保持只有 enqueue/set-state:
485
+ // 它的逐条失败会在结果里按 sessionId 报出来,此处不放宽也不收紧。
486
+ 'projection.table-delete',
458
487
  ],
459
488
  },
460
489
  list: { native: [], adapter: ['workspace.read-state', 'workspace.read-table', 'workspace.index-shape'] },
@@ -706,20 +735,34 @@ export function assessHost(ctx) {
706
735
  * 调用方:从锚点解析不到的包会被判成 `same = null`,见 assessHost 里的 `unverified`。
707
736
  */
708
737
  function hostPackageRoot() {
738
+ // 与 doctor 的 `findHost` **同一套策略**(先看 `$DSH_HOME/profiles/node_modules/@deepseek-ai`,
739
+ // 再从插件自身位置逐级上溯,并确认那一层里真有 `dsh/package.json`)。此前运行时只按"插件
740
+ // 自己解析到的包"上溯,dev 布局下会把仓库里的副本当成宿主锚点、比出假的 `true` —— 于是
741
+ // 界面说 ok、doctor 说 SEPARATE COPY(V8)。两处口径分裂本身就是缺陷。
742
+ const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh');
743
+ const candidates = [];
744
+ if (home !== '')
745
+ candidates.push(join(home, 'profiles', 'node_modules', '@deepseek-ai'));
709
746
  for (const anchor of IDENTITY_PACKAGES) {
710
747
  const resolved = realPathOf(safeResolve(anchor));
711
748
  if (resolved === null)
712
749
  continue;
713
750
  let dir = dirname(resolved);
714
- for (let i = 0; i < 3; i += 1) {
715
- if (dir.endsWith(join('node_modules', '@deepseek-ai')))
716
- return dir;
751
+ for (let i = 0; i < 4; i += 1) {
752
+ if (dir.endsWith(join('node_modules', '@deepseek-ai'))) {
753
+ candidates.push(dir);
754
+ break;
755
+ }
717
756
  const parent = dirname(dir);
718
757
  if (parent === dir)
719
758
  break;
720
759
  dir = parent;
721
760
  }
722
761
  }
762
+ for (const candidate of candidates) {
763
+ if (existsSync(join(candidate, 'dsh', 'package.json')))
764
+ return candidate;
765
+ }
723
766
  return null;
724
767
  }
725
768
  /** Human-readable summary line for logs and the settings page header. */
@@ -0,0 +1,25 @@
1
+ // 运行时降级上报通道 —— 「插件自己发现自己降级了」的统一出口。
2
+ //
3
+ // 为什么需要它:有一批降级**没有抛错可挂**:它们发生在装配期或探测期(第三方包装接管了本插件的
4
+ // 运行时适配、注入域装配失败、启动期表达式或服务名解析出问题),失败当下的表现只是"某个功能
5
+ // 少了一半"或者"静默失效"。此前这些点只落 console / logger,用户在界面上看不到,要等事故之后
6
+ // 才倒查(审查报告 §3 记了多处)。这里给它们一个共用出口:`noteRuntime` 记一条,
7
+ // `compat-status` 把全部条目并进 findings —— 兼容页因此成为"降级总账"。
8
+ //
9
+ // 与 `probe.ts` 的 CAPABILITY_SPECS 分工:那张表是**写死的宿主能力清单**(每次探测重算),
10
+ // 这里是**运行时事件**(谁在什么时候发现了什么),两者在 compat-status 里合并展示。
11
+ //
12
+ // 键是 `id`:同一主题重复上报只覆盖不堆积(它表达的是"现在的状态",不是日志)。
13
+ const notes = new Map();
14
+ /** 记一条(同 id 覆盖)。 */
15
+ export function noteRuntime(note) {
16
+ notes.set(note.id, { ...note, at: Date.now() });
17
+ }
18
+ /** 该主题已恢复正常 → 收掉这一行。 */
19
+ export function clearRuntimeNote(id) {
20
+ notes.delete(id);
21
+ }
22
+ /** 当前全部运行时降级(`compat-status` 取用)。 */
23
+ export function runtimeNotes() {
24
+ return [...notes.values()];
25
+ }
@@ -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
  }
@@ -40,7 +40,7 @@
40
40
  // 同步性:`text()` 必须同步返回(renderPrompt 不接受 Promise),而 MCP 清单是异步读盘 →
41
41
  // 与子智能体目录同构的 stale-while-revalidate(见 src/subagents/catalog.ts)。
42
42
  /** 段里最多列几台 server;超出部分只报数量。 */
43
- export const DEFAULT_MCP_MAX_ENTRIES = 50;
43
+ export const DEFAULT_MCP_MAX_ENTRIES = 60;
44
44
  /** 备注截断长度。备注是自由文本,界面侧另有 maxLength(500) 兜底,段侧再截一刀。 */
45
45
  export const DEFAULT_MCP_NOTE_MAX_LENGTH = 200;
46
46
  /**
@@ -17,7 +17,7 @@ export const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
17
17
  export const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
18
18
  export const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
19
19
  export const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
20
- export const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
20
+ export const DEFAULT_MAX_BYTES = 1 << 18; // 场景记忆段预算上限(字节)
21
21
  // ── 保留场景名 ─────────────────────────────────────────────────────────────
22
22
  /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
23
23
  export const GLOBAL_SCENE = 'global';