dsh-plugin-tool-management 0.17.0 → 0.18.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -0,0 +1,388 @@
1
+ // 工具停用的那一层边界:停用表(TTL 缓存)、「已知工具」侧车、以及把停用名单装到每个
2
+ // agent scope 上的可见性半边(restrict)+ 执行侧要用的开关写入口。
3
+ //
4
+ // 正文从 mcp/manager.ts 的 createMcpManager 闭包里整段搬来,一字未改(缩进本来就同层;
5
+ // 改的只有闭包作用域的名字走 deps —— moved-verify 按这两类容差机检):
6
+ // ensurePaths / readJsonFile / writeJsonFile / withWriteLock / message / tools /
7
+ // normalizeKnownTools,以及侧车的三件常量 SIDECAR_TTL_MS / MCP_DISABLED_TOOLS_FILE /
8
+ // MCP_KNOWN_TOOLS_FILE 与落点函数 mcpSidecar。常量按**原名**传进来,是为了让搬来的那些行
9
+ // 不必改名 —— 这一段读起来像「读一次侧车、 TTL 内不再读盘」,靠的就是那几个熟悉的名字。
10
+ // deps 形参仍叫 deps:块里那五处 `deps.carrierHiddenTools` / `deps.pluginHiddenTools` 是
11
+ // 原样文本,换名就要改行。
12
+ //
13
+ // 为什么这一簇必须整体搬:七个可见性状态量(disabledToolsCache / knownMcpToolsCache /
14
+ // restrictTimer / appliedNamesKey / desiredNames / agentRestrictions / appliedByAgent /
15
+ // seenAgents / epoch)里,只有 disabledToolsCache 被块外的 ops 段直接改写(改名、删台、
16
+ // 整表回写三处 + 出口读两处),所以它由本模块交回两个句柄:setDisabledToolsCache(按原
17
+ // 语义刷新 `{at, value}` 那份缓存)与 disabledToolsSnapshot(同步读,无 I/O —— 工具门禁在
18
+ // 热路径上)。其余名字块外零引用,跟着走就行。
19
+ import { MCP_TOOL_PREFIX } from '../host-names.js';
20
+ import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
+ export function createToolVisibility(deps) {
22
+ // ---------- per-tool enable/disable (execution + visibility boundary) ----------
23
+ // dsh-mcp-client has no per-tool config, but the DSH tool runtime exposes
24
+ // two official seams: `tools.restrict({ deny })` removes a global tool from
25
+ // every scope's model-visible schema list, and `tools.guard` denies the call
26
+ // before the body runs. Disabled tools therefore become invisible AND
27
+ // uncallable — no patch rewrite, no DSH restart.
28
+ let disabledToolsCache = null;
29
+ async function readDisabledTools(force = false) {
30
+ if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < deps.SIDECAR_TTL_MS)
31
+ return disabledToolsCache.value;
32
+ const p = await deps.ensurePaths();
33
+ const raw = await deps.readJsonFile(deps.mcpSidecar(deps.MCP_DISABLED_TOOLS_FILE));
34
+ const out = {};
35
+ if (raw && typeof raw === 'object') {
36
+ for (const serverName of Object.keys(raw)) {
37
+ const list = raw[serverName];
38
+ if (Array.isArray(list)) {
39
+ // `*` 必须原样保留:它是「整台服务器停用」的通配(场景档案未勾选的服务器写的就是它),
40
+ // 被这里过滤掉的话 guard / restrict / 快照三条链路一起失效——整台停用变成空转。
41
+ const names = list.map((name) => String(name)).filter((name) => name === '*' || /^[A-Za-z0-9_-]{1,128}$/.test(name));
42
+ if (names.length)
43
+ out[serverName] = names;
44
+ }
45
+ }
46
+ }
47
+ disabledToolsCache = { at: Date.now(), value: out };
48
+ return out;
49
+ }
50
+ /** Fully-qualified model-facing names (`mcp__<serverName>__<tool>`) of every disabled tool. */
51
+ function disabledToolNames(map) {
52
+ const out = [];
53
+ for (const serverName of Object.keys(map)) {
54
+ for (const tool of map[serverName])
55
+ out.push(MCP_TOOL_PREFIX + serverName + '__' + tool);
56
+ }
57
+ return out;
58
+ }
59
+ // ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
60
+ // MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
61
+ // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
62
+ // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
63
+ // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
64
+ let knownMcpToolsCache = null;
65
+ async function readKnownMcpTools(force = false) {
66
+ if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < deps.SIDECAR_TTL_MS)
67
+ return knownMcpToolsCache.value;
68
+ const p = await deps.ensurePaths();
69
+ const raw = await deps.readJsonFile(deps.mcpSidecar(deps.MCP_KNOWN_TOOLS_FILE));
70
+ const out = {};
71
+ if (raw && typeof raw === 'object') {
72
+ for (const serverName of Object.keys(raw)) {
73
+ const list = deps.normalizeKnownTools(raw[serverName]);
74
+ if (list.length)
75
+ out[serverName] = list;
76
+ }
77
+ }
78
+ knownMcpToolsCache = { at: Date.now(), value: out };
79
+ return out;
80
+ }
81
+ async function writeKnownMcpTools(value) {
82
+ const p = await deps.ensurePaths();
83
+ await deps.writeJsonFile(deps.mcpSidecar(deps.MCP_KNOWN_TOOLS_FILE), value);
84
+ knownMcpToolsCache = { at: Date.now(), value: value };
85
+ }
86
+ /**
87
+ * 「已知工具」侧车的**读-改-写**,整段在写锁内。
88
+ *
89
+ * 为什么不能「先 readKnownMcpTools() 再 writeKnownMcpTools()」(2026-09-30 审查 P2-15):
90
+ * 这个侧车有两个并发写者 —— 列表轮询(`mcpmListView`,每次发现新工具就回写)与
91
+ * 临时启动(`mcpmTools` 等到工具注册后回写)。两步之间是队外窗口,两个写者各读到同一份
92
+ * 旧表、各并进自己那一台服务器的工具,后写的把先写的整份覆盖掉 —— 先那台的"曾经见过"
93
+ * 记录消失,未运行时场景档案里又变成 0 工具。
94
+ *
95
+ * `mutate` 收到的是**锁内刚读出来**的表(`force` 绕 TTL,否则拿到的还是锁外那份缓存);
96
+ * 返回 `true` 表示有变化、需要落盘。
97
+ * 注意:`withWriteLock` **不可重入** —— 调用方不得已经持有写锁。
98
+ */
99
+ async function mutateKnownMcpTools(mutate) {
100
+ return deps.withWriteLock(async () => {
101
+ const known = await readKnownMcpTools(true);
102
+ if (!mutate(known))
103
+ return;
104
+ await writeKnownMcpTools(known);
105
+ });
106
+ }
107
+ /**
108
+ * 把「这次 live 见到的工具」并进一份已知工具表;返回是否有变化。
109
+ *
110
+ * 抽出来是为了让"要不要写盘"的判断与"锁内真正合并"用**同一个**函数:调用方先在锁外
111
+ * 对本地缓存跑一遍拿到 `changed`(决定要不要进锁),进锁后再对新鲜表跑一遍(那次的结果
112
+ * 才决定落盘)—— 两边口径不一致的话,要么白写要么漏写。
113
+ */
114
+ function mergeLiveToolsInto(known, live) {
115
+ let changed = false;
116
+ for (const [server, list] of Object.entries(live)) {
117
+ const prev = known[server] || [];
118
+ const byName = new Map(prev.map((t) => [t.name, t]));
119
+ for (const tool of list) {
120
+ const old = byName.get(tool.name);
121
+ if (!old) {
122
+ byName.set(tool.name, tool);
123
+ changed = true;
124
+ continue;
125
+ }
126
+ if (tool.description && old.description !== tool.description) {
127
+ byName.set(tool.name, { ...old, description: tool.description });
128
+ changed = true;
129
+ }
130
+ }
131
+ const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
132
+ if (next.length !== prev.length)
133
+ changed = true;
134
+ known[server] = next;
135
+ }
136
+ return changed;
137
+ }
138
+ async function mcpmToolEnabled(args) {
139
+ const serverName = String((args && args.serverName) || '').trim();
140
+ const tool = String((args && args.tool) || '').trim();
141
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(serverName))
142
+ return { ok: false, error: 'serverName 不合法' };
143
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(tool))
144
+ return { ok: false, error: 'tool 名称不合法' };
145
+ const enabled = (args && args.enabled) !== false;
146
+ const p = await deps.ensurePaths();
147
+ return deps.withWriteLock(async () => {
148
+ // Force re-read so an externally edited disabled-tools file is merged, not clobbered.
149
+ const map = Object.assign({}, await readDisabledTools(true));
150
+ const disabled = new Set(map[serverName] || []);
151
+ if (enabled)
152
+ disabled.delete(tool);
153
+ else
154
+ disabled.add(tool);
155
+ if (disabled.size)
156
+ map[serverName] = [...disabled].sort();
157
+ else
158
+ delete map[serverName];
159
+ try {
160
+ await deps.writeJsonFile(deps.mcpSidecar(deps.MCP_DISABLED_TOOLS_FILE), map);
161
+ }
162
+ catch (e) {
163
+ return { ok: false, error: '保存失败: ' + deps.message(e) };
164
+ }
165
+ disabledToolsCache = { at: Date.now(), value: map };
166
+ await applyToolRestrictions();
167
+ return { ok: true, serverName, disabled: map[serverName] || [] };
168
+ });
169
+ }
170
+ // Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
171
+ // the tool set / the disabled set / this agent's carrier set changes. Three facts decide
172
+ // this design(都读过官方源码 dsh-tools 0.1.5-rc.2):
173
+ // · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
174
+ // context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
175
+ // · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
176
+ // (`layers = new ScopedLayers(…, () => this.ctx.emit("tools/change"))`),而重放正挂在
177
+ // `tools/change` 上 —— 名单没变还重放就是自激;
178
+ // · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
179
+ // 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
180
+ // (2026-09-23:名单有三处来源,其中 `carrierHiddenTools` 逐 agent 不同,所以从"一份名单给
181
+ // 所有 agent"改成逐 agent 各装一层 —— 见下面 restrictAgent / appliedByAgent 的注释。)
182
+ let restrictTimer = null;
183
+ /** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
184
+ let appliedNamesKey = null;
185
+ /** 当前期望的**全局** deny 名单(agent 晚到 / 重放时用它,再叠加该 agent 自己那份)。 */
186
+ let desiredNames = [];
187
+ /** agent → 撤掉这层限制的 disposer。 */
188
+ const agentRestrictions = new Map();
189
+ /** agent → 已应用的**合并后**名单键(名单现在与 agent 有关,不能再只记一份全局的)。 */
190
+ const appliedByAgent = new Map();
191
+ /** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
192
+ const seenAgents = new Set();
193
+ /**
194
+ * 重放代数:`carrierHiddenTools` 是异步的,算名单期间可能又来了新的一轮重放(或 agent
195
+ * 已经下线)。每次重放自增,回来时代数对不上就作废 —— 否则会用旧名单盖掉新的那一层。
196
+ */
197
+ let epoch = 0;
198
+ function scopedToolsOf(agent) {
199
+ const scoped = agent && agent.ctx && agent.ctx.tools;
200
+ return scoped && typeof scoped === 'object' ? scoped : undefined;
201
+ }
202
+ /** 撤掉某个 agent 上的限制(名单变了要重装:`restrict` 是叠加层,装新的不撤旧的会越叠越多)。 */
203
+ function liftRestriction(agent) {
204
+ const release = agentRestrictions.get(agent);
205
+ agentRestrictions.delete(agent);
206
+ appliedByAgent.delete(agent);
207
+ if (release) {
208
+ try {
209
+ release();
210
+ }
211
+ catch (e) { /* ignore */ }
212
+ }
213
+ }
214
+ /** 把该 agent 该背的名单装到它 scope 上(全局那份 ∪ 它自己那份;空名单 = 不碰它)。 */
215
+ async function restrictAgent(agent) {
216
+ const mine = epoch;
217
+ let extra = [];
218
+ if (deps.carrierHiddenTools) {
219
+ // 判据失败一律当"没有额外要藏的":藏错了模型就少一条路,留着只是多花 token。
220
+ try {
221
+ extra = await deps.carrierHiddenTools(agent);
222
+ }
223
+ catch (e) {
224
+ extra = [];
225
+ }
226
+ }
227
+ if (mine !== epoch)
228
+ return;
229
+ if (!seenAgents.has(agent))
230
+ return;
231
+ const names = [...new Set([...desiredNames, ...extra])];
232
+ const scoped = scopedToolsOf(agent);
233
+ if (!scoped || typeof scoped.restrict !== 'function') {
234
+ if (names.length === 0)
235
+ return;
236
+ // agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
237
+ // 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
238
+ noteRuntime({
239
+ id: 'mcp-tool-visibility',
240
+ label: '停用工具的可见性',
241
+ kind: 'write',
242
+ fallback: 'inform-only',
243
+ detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
244
+ detailKey: 'mcp-tool-visibility.no-restrict',
245
+ });
246
+ return;
247
+ }
248
+ const key = names.slice().sort().join('\u0000');
249
+ if (appliedByAgent.get(agent) === key)
250
+ return;
251
+ liftRestriction(agent);
252
+ if (names.length === 0) {
253
+ clearRuntimeNote('mcp-tool-visibility');
254
+ return;
255
+ }
256
+ try {
257
+ const dispose = scoped.restrict({ deny: names });
258
+ agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
259
+ appliedByAgent.set(agent, key);
260
+ clearRuntimeNote('mcp-tool-visibility');
261
+ }
262
+ catch (e) {
263
+ // 这个名字在这个 scope 里认不出 / scope 已经收了:可见性半边没生效。执行侧的 guard
264
+ // 仍然拦住调用,所以功能不缺 —— 但必须如实上报,不能假装成功(此前正是静默吞掉)。
265
+ noteRuntime({
266
+ id: 'mcp-tool-visibility',
267
+ label: '停用工具的可见性',
268
+ kind: 'write',
269
+ fallback: 'inform-only',
270
+ detail: '有工具没能从模型可见的工具表里摘掉(' + deps.message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
271
+ detailKey: 'mcp-tool-visibility.partial',
272
+ params: { reason: deps.message(e) },
273
+ });
274
+ }
275
+ }
276
+ /** agent 上线(`agent/created` 或启动期的 agent 列表):登记并立刻应用当前名单。 */
277
+ function attachAgent(agent) {
278
+ if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
279
+ return;
280
+ seenAgents.add(agent);
281
+ void restrictAgent(agent).catch(() => { });
282
+ }
283
+ /** agent 下线(`agent/disposed`):先撤限制再销登记。 */
284
+ function detachAgent(agent) {
285
+ seenAgents.delete(agent);
286
+ liftRestriction(agent);
287
+ }
288
+ async function applyToolRestrictions() {
289
+ // 全局 tools 面上没有 restrict → 可见性半边整条做不成,如实上报(2026-09-30 审查 P2-8)。
290
+ // 此前这里是**裸 return**:停用工具仍留在模型可见的工具表里,而兼容页因为没有上报仍报
291
+ // 一行"ok",用户看到的是"停用了但工具还在"。与 `restrictAgent` 里那条 noteRuntime 同文案、
292
+ // 同 id(noteRuntime 按 id 覆盖,两处不会互相刷屏)。
293
+ if (typeof deps.tools.restrict !== 'function') {
294
+ noteRuntime({
295
+ id: 'mcp-tool-visibility',
296
+ label: '停用工具的可见性',
297
+ kind: 'write',
298
+ fallback: 'inform-only',
299
+ detail: '全局 tools 服务上没有可用的 tools.restrict(官方接口变了):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
300
+ detailKey: 'mcp-tool-visibility.no-restrict',
301
+ });
302
+ return;
303
+ }
304
+ // Force re-read: this runs on the tools/change path, which is rare, so a
305
+ // stale cache must not keep an externally edited deny list hidden.
306
+ const map = await readDisabledTools(true);
307
+ // 通配 `*`(整服务器停用)先展开成已注册的全名,再与精确名单合并。
308
+ const wanted = disabledToolNames(map);
309
+ let registered;
310
+ try {
311
+ registered = new Set((await deps.tools.schemas()).map((schema) => String(schema.name)));
312
+ }
313
+ catch (e) {
314
+ return;
315
+ }
316
+ for (const [serverName, list] of Object.entries(map)) {
317
+ if (list.indexOf('*') < 0)
318
+ continue;
319
+ const prefix = MCP_TOOL_PREFIX + serverName + '__';
320
+ for (const fullName of registered)
321
+ if (fullName.startsWith(prefix))
322
+ wanted.push(fullName);
323
+ }
324
+ // 兼容页「模型工具表」关掉的本插件工具并进同一份名单(理由见 deps.pluginHiddenTools)。
325
+ // 交给上面那个 `registered` 过滤是**必要的**而不是保险:官方对认不出的名字直接抛错,
326
+ // 而关掉的工具里可能有一个这次根本没注册成功(注册失败会被子智能体页的黄条报出来)。
327
+ if (deps.pluginHiddenTools) {
328
+ try {
329
+ for (const name of deps.pluginHiddenTools())
330
+ wanted.push(name);
331
+ }
332
+ catch (e) { /* 读设置失败 = 这次不摘它们 */ }
333
+ }
334
+ const names = [...new Set(wanted)].filter((name) => registered.has(name));
335
+ const key = names.join('\u0000');
336
+ // 有 `carrierHiddenTools` 时不拿全局键短路:那份名单与"官方工具此时挂没挂"有关,
337
+ // 而它变了不会改全局键(官方挂载/卸载本身就发 tools/change,正是靠这条通路追上)。
338
+ // 代价是每次 tools/change 都重算一遍 —— 逐 agent 的键判等会把绝大多数挡在装层之前,
339
+ // 事实读取也有 TTL 缓存;装层本身会再发一次 tools/change,那一轮键相同即收敛,不自激。
340
+ if (key === appliedNamesKey && deps.carrierHiddenTools === undefined)
341
+ return;
342
+ appliedNamesKey = key;
343
+ desiredNames = names;
344
+ // 每次重放先撤掉每个 agent 上的旧层:名单变短(重新启用某个工具)时,只有撤掉这层限制
345
+ // 它才会重新可见;名单变了(含逐 agent 那份)也必须重装而不是叠加。
346
+ epoch += 1;
347
+ for (const agent of [...agentRestrictions.keys()])
348
+ liftRestriction(agent);
349
+ for (const agent of seenAgents)
350
+ void restrictAgent(agent).catch(() => { });
351
+ }
352
+ function scheduleToolRestrictions() {
353
+ if (restrictTimer)
354
+ return;
355
+ restrictTimer = setTimeout(() => {
356
+ restrictTimer = null;
357
+ applyToolRestrictions().catch(() => { });
358
+ }, 300);
359
+ }
360
+ /** 卸载清理:清掉重排定时器、把挂在每个 agent scope 上的限制都撤掉(母文件 dispose 的那六行原样搬来)。 */
361
+ function disposeVisibility() {
362
+ if (restrictTimer) {
363
+ clearTimeout(restrictTimer);
364
+ restrictTimer = null;
365
+ }
366
+ // 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
367
+ epoch += 1;
368
+ for (const agent of [...agentRestrictions.keys()])
369
+ liftRestriction(agent);
370
+ appliedByAgent.clear();
371
+ seenAgents.clear();
372
+ }
373
+ return {
374
+ readDisabledTools,
375
+ readKnownMcpTools,
376
+ writeKnownMcpTools,
377
+ mutateKnownMcpTools,
378
+ mergeLiveToolsInto,
379
+ mcpmToolEnabled,
380
+ attachAgent,
381
+ detachAgent,
382
+ applyToolRestrictions,
383
+ scheduleToolRestrictions,
384
+ setDisabledToolsCache: (value) => { disabledToolsCache = { at: Date.now(), value }; },
385
+ disabledToolsSnapshot: () => (disabledToolsCache ? disabledToolsCache.value : {}),
386
+ disposeVisibility,
387
+ };
388
+ }
@@ -58,6 +58,24 @@ export function createArchiveEngine(deps) {
58
58
  const byRoot = new Map(current.map((s) => [s.root, s.enabled]));
59
59
  return rows.filter((x) => byRoot.has(x.root) && byRoot.get(x.root) !== x.enabled);
60
60
  }
61
+ /**
62
+ * 快捷提示词段 → 应用(勾了的启用、未勾的停用),只写**状态需要变化**的那几条。
63
+ *
64
+ * 只在档案**定义了**这一段时被调用:未定义 = 这一域完全不碰(与 mcp/skills/subagents 的
65
+ * 「未定义 = 全关」不同,见 `SceneArchive.quickPrompts` 的注释)。
66
+ */
67
+ async function applyQuickPrompts(list) {
68
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
69
+ const switches = [];
70
+ for (const id of known) {
71
+ const want = list.indexOf(id) >= 0;
72
+ if (current[id] === want)
73
+ continue;
74
+ switches.push({ id, enabled: want });
75
+ }
76
+ if (switches.length)
77
+ await deps.applyQuickPromptSwitches(switches);
78
+ }
61
79
  /**
62
80
  * 还原到快照(退出模式与失败回滚共用同一条路径):
63
81
  * MCP 停用表**按快照原文整体回写**——模式自己写进去的键(未勾服务器的 ['*'])必须随之消失,
@@ -67,6 +85,20 @@ export function createArchiveEngine(deps) {
67
85
  * 上层两行(服务器级 / 来源级)按**全量**快照还原 —— 只回写与现状不同的行,见
68
86
  * mcpServerRowsToRestore / skillSourceRowsToRestore。
69
87
  */
88
+ /**
89
+ * 两份名单是不是同一批名字(顺序无关)。
90
+ *
91
+ * 现在只有工具表那一档用它:档案绑的方案与当前现状是同一份关停名单时,进入这一步其实
92
+ * 什么都没改 —— 回执要不要提「工具表切到方案 X」就取决于这一句。
93
+ */
94
+ function sameNameList(a, b) {
95
+ if (!a || !b)
96
+ return false;
97
+ if (a.length !== b.length)
98
+ return false;
99
+ const set = new Set(a);
100
+ return b.every((x) => set.has(x));
101
+ }
70
102
  async function restoreSnapshot(snapshot) {
71
103
  // 顺序与进入时**相反**:先恢复服务器级 / 来源级,再恢复工具级 / 技能级 ——
72
104
  // 来源还关着的时候写技能级策略会被吞掉(`skills/core.js` 的 sourceEnabled 判定)。
@@ -108,11 +140,48 @@ export function createArchiveEngine(deps) {
108
140
  await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
109
141
  }
110
142
  }
143
+ // 快捷提示词(0.18.0):按快照的全量映射还原,但**只回写与现状不同的那几条** ——
144
+ // 与 mcpServerRowsToRestore 同一条理由:没变过的行不该白写一遍侧车。读不到现状就退回全量
145
+ // 回写(宁可多写几行,不可少还原)。老快照没有这一栏 → 一个字都不动(进场景时也没碰过)。
146
+ // 场景期间被删掉的条目由实现侧静默跳过:没有可还原的状态,硬写会把"退出场景"卡成报错。
147
+ const quickStates = snapshot.quickPromptsAll;
148
+ if (quickStates && typeof quickStates === 'object') {
149
+ const rows = Object.entries(quickStates);
150
+ if (rows.length) {
151
+ let current = null;
152
+ try {
153
+ current = await deps.quickPromptStates();
154
+ }
155
+ catch {
156
+ current = null;
157
+ }
158
+ const switches = rows
159
+ .filter(([id, on]) => current === null || current[id] !== (on === true))
160
+ .map(([id, on]) => ({ id, enabled: on === true }));
161
+ if (switches.length)
162
+ await deps.applyQuickPromptSwitches(switches);
163
+ }
164
+ }
111
165
  // 模型工具表(C1):按快照原值整体回写。老快照没有这一栏 → 一个字都不动(与进场景前一致)。
166
+ // 先与现状比一次:绑的方案与进场景前本来就是同一份名单时,这一步什么都没改 —— 既不该白跑
167
+ // 一遍"落盘 → 重排可见性 → 两个目录重算",回执更不该跟着说「工具表按快照还原」(用户问
168
+ // "刚才改了什么"时凭空多一项)。读不到现状就照旧回写(宁可多写一遍,不可少还原)。
169
+ let toolTableRestored = false;
112
170
  if (snapshot.toolTableHidden) {
113
- await deps.applyToolTableHidden(snapshot.toolTableHidden);
171
+ let current = null;
172
+ try {
173
+ current = await deps.currentToolTableHidden();
174
+ }
175
+ catch {
176
+ current = null;
177
+ }
178
+ if (current === null || !sameNameList(current, snapshot.toolTableHidden)) {
179
+ await deps.applyToolTableHidden(snapshot.toolTableHidden);
180
+ toolTableRestored = true;
181
+ }
114
182
  }
115
183
  deps.audit?.('scene-restore', '');
184
+ return { toolTable: toolTableRestored };
116
185
  }
117
186
  /**
118
187
  * 失败回滚:运行时还原 + 切片写回;每步失败都记下来,绝不谎报「已回滚」。
@@ -179,6 +248,10 @@ export function createArchiveEngine(deps) {
179
248
  if (entries.length)
180
249
  await deps.applyMcpNotes(entries);
181
250
  }
251
+ // 快捷提示词:段定义了才碰(未定义 = 保持现状)。快照里的原值**不动** ——
252
+ // 退出场景仍回到「进场景前」那批开关,而不是回到上一次档案的勾选。
253
+ if (archive && Array.isArray(archive.quickPrompts))
254
+ await applyQuickPrompts(archive.quickPrompts);
182
255
  // 工具表方案(C1):改档案 = 立刻生效,这一域也得跟上。快照里的原值**不动** ——
183
256
  // 退出场景仍然回到「进场景前」那份名单,而不是回到上一次档案的方案。
184
257
  const reapplyPreset = (archive && archive.toolTablePreset) || '';
@@ -241,6 +314,7 @@ export function createArchiveEngine(deps) {
241
314
  mcpServers: Object.keys(snap.mcp || {}).length,
242
315
  skills: Object.keys(snap.skills || {}).length,
243
316
  subagents: personaStates ? Object.keys(personaStates).length : (snap.subagents ?? []).length + (snap.subagentsOn ?? []).length,
317
+ quickPrompts: Object.keys(snap.quickPromptsAll || {}).length,
244
318
  mcpNotes: (snap.mcpNotes ?? []).length,
245
319
  },
246
320
  // 记忆启用集**不随退出恢复**(设计 §2.2):退出后仍是这个场景,界面别把它写成"会还原"。
@@ -285,6 +359,27 @@ export function createArchiveEngine(deps) {
285
359
  catch (e) {
286
360
  return { ok: false, error: `读取人设开关状态失败:${msg(e)}` };
287
361
  }
362
+ // 快捷提示词:只有档案**定义了**这一段才报(未定义 = 这一域进都不进,也就没有"会改什么")。
363
+ // 与进入时逐字同口径:勾了的启用、未勾的停用,且只数**现状 ≠ 目标**的那几条。
364
+ let quickPrompts = null;
365
+ if (archive && Array.isArray(archive.quickPrompts)) {
366
+ try {
367
+ const list = archive.quickPrompts;
368
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
369
+ const on = [];
370
+ const off = [];
371
+ for (const id of [...known].sort()) {
372
+ const want = list.indexOf(id) >= 0;
373
+ if (current[id] === want)
374
+ continue;
375
+ (want ? on : off).push(id);
376
+ }
377
+ quickPrompts = { on: clip(on), off: clip(off) };
378
+ }
379
+ catch (e) {
380
+ return { ok: false, error: `读取快捷提示词开关状态失败:${msg(e)}` };
381
+ }
382
+ }
288
383
  // 服务器级:计划里已只含「需要变化」的行,按目标状态分两个方向。
289
384
  const serversOn = [];
290
385
  const serversOff = [];
@@ -406,6 +501,7 @@ export function createArchiveEngine(deps) {
406
501
  sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
407
502
  },
408
503
  subagents: { on: clip(personas.on), off: clip(personas.off) },
504
+ quickPrompts,
409
505
  toolPreset,
410
506
  stale: clip(stale),
411
507
  truncated,
@@ -465,6 +561,14 @@ export function createArchiveEngine(deps) {
465
561
  if (hadKeys && archive.subagents.length === 0)
466
562
  delete archive.subagents;
467
563
  }
564
+ if (archive.quickPrompts) {
565
+ // 已删掉 / 改名的条目从勾选集里丢掉并报出来(与 subagents 段同一口径)。
566
+ // 与那三段**不同**的一点:清成空表也**不删这一栏** —— 空表说的是"进入后全部停用",
567
+ // 删掉就变成"这一域不碰",两句是相反的意思(见 SceneArchive.quickPrompts)。
568
+ const known = await deps.knownQuickPrompts();
569
+ stale.push(...archive.quickPrompts.filter((id) => !known.has(id)).map((id) => 'quickPrompts/' + id));
570
+ archive.quickPrompts = archive.quickPrompts.filter((id) => known.has(id));
571
+ }
468
572
  if (archive.toolTablePreset) {
469
573
  // 方案名查不到(被删了 / 档案是从别的机器带来的)→ 丢弃这一栏并报告:
470
574
  // 绑一个不存在的方案不该在进场景时变成一句"方案不存在"的硬失败。
@@ -488,6 +592,13 @@ export function createArchiveEngine(deps) {
488
592
  if (prev && typeof prev.toolTablePreset === 'string' && prev.toolTablePreset)
489
593
  archive.toolTablePreset = prev.toolTablePreset;
490
594
  }
595
+ // 快捷提示词同理:勾选入口在「修改场景」表单里,档案编辑器不带这一栏 —— **不带 ≠ 解绑**,
596
+ // 保留现值(解绑走 rules-update-scene 的空值;这里带上来的值仍走上面的 stale 校验)。
597
+ if (archive.quickPrompts === undefined) {
598
+ const prev = slice.archives[scene];
599
+ if (prev && Array.isArray(prev.quickPrompts))
600
+ archive.quickPrompts = prev.quickPrompts.slice();
601
+ }
491
602
  // 逐场景增量写(审查 P2-3):这里只该动 `archives[scene]` 这一条,而 `slice` 是队外
492
603
  // 读的 —— 整片回写会把窗口期里别人保存的**别的场景**档案一起抹掉。
493
604
  const archiveNext = Object.keys(archive).length === 0 ? null : archive;
@@ -532,9 +643,10 @@ export function createArchiveEngine(deps) {
532
643
  return { ok: true, mode: current, applied: null, stale: [] };
533
644
  // ① 退出当前模式(切换 = 先退后进):先恢复运行时,再落盘自由模式。
534
645
  // 恢复失败即中止且**保持原 mode 不变**——宁可留在「可再退出一次」的状态,也不吞错。
646
+ let restoredToolTable = false;
535
647
  if (current.scene && current.snapshot) {
536
648
  try {
537
- await restoreSnapshot(current.snapshot);
649
+ restoredToolTable = (await restoreSnapshot(current.snapshot)).toolTable;
538
650
  }
539
651
  catch (e) {
540
652
  return { ok: false, error: `退出模式「${current.scene}」失败,已保持原模式(运行时可能部分残留):${msg(e)}` };
@@ -554,7 +666,7 @@ export function createArchiveEngine(deps) {
554
666
  current = exited.mode;
555
667
  }
556
668
  if (!target)
557
- return { ok: true, mode: current, applied: null, stale: [] };
669
+ return { ok: true, mode: current, applied: null, stale: [], restored: { toolTable: restoredToolTable } };
558
670
  // ② 进入目标模式:校验 → 算计划 → 拍快照 → 先落盘 mode(中途失败也留可退快照)→ 应用 → 记忆收窄。
559
671
  // 三个域(MCP / 技能 / 人设)都按「与档案勾选集完全一致」应用,未定义 = 全关;
560
672
  // 备注段只在定义时覆盖。所以这里没有「纯记忆场景不用动运行时」这条分支 —— 恒拍快照。
@@ -660,11 +772,28 @@ export function createArchiveEngine(deps) {
660
772
  return { ok: false, error: `场景绑定的工具表方案「${toolTablePreset}」已不存在,未进入场景(先在场景档案里换一份或取消绑定)` };
661
773
  }
662
774
  }
775
+ // 这一档"报不报"看的是**名单有没有变**:绑的方案与现状是同一份关停名单时,进入这一步
776
+ // 什么都没改,回执再说「工具表切到方案 X」就是给"刚才改了什么"凭空添一项。
777
+ const toolTableSwitched = !!toolTableTarget && !sameNameList(toolTableBefore, toolTableTarget);
778
+ // 快捷提示词(0.18.0):档案**定义了**这一段才碰这一域(未定义 = 保持现状,与
779
+ // mcp/skills/subagents 的「未定义 = 全关」不同 —— 用户 2026-10-01 裁定这一栏的默认是
780
+ // 「当前启用的快捷提示词」)。读现状同样放在改任何东西之前:读失败就等于什么都没动。
781
+ const quickList = archive && Array.isArray(archive.quickPrompts) ? archive.quickPrompts : null;
782
+ let quickStates = null;
783
+ if (quickList) {
784
+ try {
785
+ const [known, current] = await Promise.all([deps.knownQuickPrompts(), deps.quickPromptStates()]);
786
+ quickStates = Object.fromEntries([...known].map((id) => [id, current[id] === true]));
787
+ }
788
+ catch (e) {
789
+ return { ok: false, error: `读取快捷提示词开关状态失败(未改动任何东西):${msg(e)}` };
790
+ }
791
+ }
663
792
  // 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
664
793
  // 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
665
794
  // 上层两行传**现状全量**(上面的 mcpServerStates / skillSources 就是进场景前的值),
666
795
  // 不是计划里那几行 —— 退出按「现状 ≠ 记录值」回写(见 mcpServerRowsToRestore)。
667
- const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore);
796
+ const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore, quickStates);
668
797
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
669
798
  try {
670
799
  // 先落 **mode**(留可退快照);active 留到最后一步再落 —— 中途失败要能按原样退回,
@@ -705,6 +834,10 @@ export function createArchiveEngine(deps) {
705
834
  // 所以它是"进入场景"这一动作里唯一直接改每请求内容的域)。
706
835
  if (toolTableTarget)
707
836
  await deps.applyToolTableHidden(toolTableTarget);
837
+ // ⑥ 快捷提示词:段定义了才动(勾了的启用、未勾的停用)。它不进上下文、不进注入,
838
+ // 改的只是"这条还在不在对话框的 `/` 菜单里"。
839
+ if (quickList)
840
+ await applyQuickPrompts(quickList);
708
841
  }
709
842
  catch (e) {
710
843
  return await rollback(slice, snapshot, '应用档案', e);
@@ -723,12 +856,16 @@ export function createArchiveEngine(deps) {
723
856
  mode: entered.mode,
724
857
  // 三个域都是「按勾选集全量应用」(未定义 = 全关),所以都算已应用;
725
858
  // 备注段未定义 = 不覆盖任何备注,如实报它是否定义。
726
- applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget },
859
+ applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget, quickPrompts: !!quickList },
727
860
  // 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
728
861
  switched: {
729
862
  mcpServers: mcpPlan.serverSwitches.length,
730
863
  skillSources: skillsPlan.sourceSwitches.length,
864
+ // 工具表:这次真的换上去的关停条数(0 = 本来就是那份,或压根没绑方案)。
865
+ toolTableHidden: toolTableSwitched ? toolTableTarget.length : 0,
731
866
  },
867
+ // 换上去的那份方案名,供回执点名(与上面同一个判据:没换就回空串)。
868
+ toolTablePreset: toolTableSwitched ? toolTablePreset : '',
732
869
  stale,
733
870
  narrowedTo: [target],
734
871
  };