dsh-plugin-tool-management 0.20.3 → 0.21.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +9 -5
  3. package/README_EN.md +9 -5
  4. package/docs/images/icon.png +0 -0
  5. package/docs/images/icon.svg +1 -0
  6. package/docs/update.md +2 -0
  7. package/lib/client.js +829 -153
  8. package/lib/compat/capability-specs.js +60 -1
  9. package/lib/compat/host-contracts.js +72 -0
  10. package/lib/compat/host-probe.js +59 -0
  11. package/lib/compat/host-roots.js +1 -1
  12. package/lib/compat/probe.js +84 -13
  13. package/lib/context-inject-frame.js +168 -29
  14. package/lib/context-inject.js +13 -10
  15. package/lib/http-api.js +3 -2
  16. package/lib/http-fence.js +26 -1
  17. package/lib/index.js +34 -7
  18. package/lib/mcp/manager.js +6 -1
  19. package/lib/mcp/state-section.js +75 -34
  20. package/lib/memories/constants.js +39 -7
  21. package/lib/memories/projection.js +18 -18
  22. package/lib/memories/service.js +8 -7
  23. package/lib/memories/snapshot.js +39 -21
  24. package/lib/op-registry.js +3 -0
  25. package/lib/ops/compat.js +64 -2
  26. package/lib/ops/memory.js +2 -2
  27. package/lib/ops/prompts.js +30 -2
  28. package/lib/ops/trash.js +12 -1
  29. package/lib/request-gate.js +5 -4
  30. package/lib/settings-sidecars.js +37 -0
  31. package/lib/sidebar-settings.js +13 -0
  32. package/lib/skills/catalog.js +2 -2
  33. package/lib/subagents/catalog.js +4 -4
  34. package/lib/subagents/child-preset.js +120 -0
  35. package/lib/subagents/persona-parse.js +9 -9
  36. package/lib/subagents/run-provider.js +218 -62
  37. package/lib/subagents/tool-filter.js +12 -12
  38. package/lib/subagents/tools.js +29 -22
  39. package/lib/tools/deps.js +4 -4
  40. package/lib/tools/mcp.js +34 -34
  41. package/lib/tools/memory.js +34 -34
  42. package/lib/tools/prompt.js +5 -5
  43. package/lib/tools/remove.js +17 -17
  44. package/lib/tools/scene.js +66 -66
  45. package/lib/tools/skills.js +21 -21
  46. package/lib/tools/subagent.js +13 -13
  47. package/lib/tools/table.js +2 -2
  48. package/locale/en.json +6 -0
  49. package/locale/zh.json +6 -0
  50. package/package.json +28 -24
@@ -8,11 +8,23 @@
8
8
  * 为什么不能并进 `cue`(那能省下整整一行 ≈16 tok/段):用户 2026-09-18 定过"权威声明单独
9
9
  * 成句" —— 它和动作句是两种东西(一句说"什么时候用它",一句说"以哪份为准"),合并后容易
10
10
  * 被一眼带过。所以这里的收益只有约 6 tok/轮,**主要收益是消除四种措辞**,不是省字节。
11
+ *
12
+ * 2026-10-18(用户裁定):**模型侧模板一律英文** —— 框架标题、取代声明、`how` 行、清空 /
13
+ * 截断 / 未列出这些提示。**用户自己写的内容一律原样注入**(场景名与说明、记忆正文、MCP
14
+ * 备注、技能与人设的描述、`AGENTS.md` 正文、路径),一个字符都不动。理由:官方两条注入行
15
+ * (`Instructions from:` 与 skill catalog)本来就是英文,混着中文只让模型多付一次对齐成本。
16
+ *
17
+ * 同日晚些时候二次收紧(用户:「让 agent 更能注意到信息,信息高效,也不要冗余」):这一代
18
+ * 每个句子都按「少一个词不改行为就删」过了一遍 —— 权威声明去掉自我指涉与场合限定
19
+ * (`This replaces … in this session` → `Supersedes … of the same kind`),六个 `how` 行
20
+ * 去掉「This preset has no …」这类铺垫、改成先说要做什么。**没有新增任何一行**:2026-09-23
21
+ * 那条原则(注入只讲当前情况、不写解释与动作句)仍然生效,加字只会稀释指令。
11
22
  */
12
- export const SUPERSEDE_NOTE = '本份取代本次会话中更早注入的同类内容。';
23
+ export const SUPERSEDE_NOTE = 'Supersedes earlier injected content of the same kind.';
13
24
  export const DOMAIN_FRAME = {
14
25
  scene: {
15
- // 场景段只有标题 + 正文 + 权威声明(**没有 cue 是六个域的共同决定**,理由见 `DomainFrame`)。
26
+ // 场景段此前只有标题 + 正文 + 权威声明(没有 cue 是六个域的共同决定,理由见 `DomainFrame`);
27
+ // 2026-10-18 起多了一句 `how`(定义 + 后果),为什么加、加的是哪一句,见本段末尾。
16
28
  //
17
29
  // 这一段的演进值得记下来,因为每一次都是被实际注入推着改的:
18
30
  // ① 最早它把「场景说明」当**约定**授权("一律照办,覆盖你的默认做法")—— 而它的实例
@@ -24,33 +36,119 @@ export const DOMAIN_FRAME = {
24
36
  // 知道没用的信息,反推更是浪费 token」。于是三句全删。
25
37
  //
26
38
  // 现在这一段只回答一个问题:**当前处在哪个场景、它是什么**(`**「代码」—— 写代码**`)。
27
- // 它还比别的段少一层:因果句也删了 —— 它解释的是"另外四段是怎么产生的"(机制),
28
- // 不是当前情况本身,而且"清单里没有 ≠ 本机没有"这层反推被用户明确判为浪费。
29
- title: '当前场景',
39
+ //
40
+ // 2026-10-18(用户看到实际注入后问「agent 怎么知道这个场景是做什么的」):把②那次删掉的
41
+ // 定义**加回来,但只加一句**,且换掉当时失败的那个写法。
42
+ // - 当时失败的是"场景是用户给这台机器配的工作模式"——**只说是什么,不说它带来了什么**,
43
+ // 模型读完仍无动作可做。这里补上后果:记忆 / 提示词 / 工具 / 人设都已经按它筛过。
44
+ // - 与①的区别(那条授权语不能回来):这里不授权"照办",只说"你拿到的这些东西为什么长这样"。
45
+ // 用户自己写的说明仍然只是注解,不是约定。
46
+ // - 为什么这句能过"不写机制"那条原则:它解释的不是宿主怎么算的,而是**模型眼前这份上下文
47
+ // 为什么缺了几样**(`## Scene: 编程` 分组、看不到别的场景的记忆、工具比别处少)。
48
+ // 这是它做判断时要用的事实,不是反推。
49
+ // - 代价 ≈24 tok/轮,只在这一域。
50
+ title: 'Current scene',
51
+ how: "A scene is the user's working mode: the memories, prompt, tools and personas you are given are already filtered to it.",
30
52
  supersede: SUPERSEDE_NOTE,
31
53
  },
32
54
  memory: {
33
- title: '当前记忆',
55
+ title: 'Current memories',
56
+ // 2026-10-18(审查 #7):记忆段截断时,折叠行 `- ... ({rest} more not listed)` 给了计数
57
+ // 但没给出口——被截掉的条目连名字都没列出来,模型既调不了 `memory_manager_read`(不知道
58
+ // 传什么名字),也不知道该用 `memory_manager_list` 去发现。另外三个域的溢出行都点名了
59
+ // 自己的 list 工具,唯独记忆段没有。
60
+ //
61
+ // 修法选了「frame `how` 条件点名」而不是「把 listToolVisible 管道接到记忆服务」:记忆 SWR
62
+ // 缓存是指纹驱动的(缓存渲染结果,不是缓存数据按 agent 重渲染),重构成技能目录那种方式
63
+ // 要动 4–5 个文件。而 `how` 已经能看 `{ text }`——MCP 就是这么按正文内容决定发不发的。
64
+ // `text.includes('more not listed')` 只在折叠行出现时命中(rest > 0);rest = 0(清单列全)
65
+ // 时折叠行不出现,how 不发——那些条目有名字,模型调 `memory_manager_read` 就行。
66
+ // 工具被关掉(兼容页 / 人设 toolsDeny)时不点名——与另外三域同一纪律。
67
+ how: ({ text, toolHidden }) => {
68
+ // 折叠行 `more not listed` 和截断标记 `(Truncated:` 两种信号都要覆盖:
69
+ // - 折叠行 = 清单部分列了 + 剩余没列 → 没名的那些需要 list 工具发现
70
+ // - 标记 = 连清单都没放进来 → 全部没名,更需要 list 工具
71
+ // - 清单列全(`## Not injected` + 所有名字,rest = 0)→ 两种信号都没有 → 不发 how:
72
+ // 模型用 `memory_manager_read` 按名字读就行
73
+ if (!text.includes('more not listed') && !text.includes('(Truncated:'))
74
+ return undefined;
75
+ if (toolHidden('memory_manager_list'))
76
+ return undefined;
77
+ return 'Not all memories fit here — see the rest with `memory_manager_list`.';
78
+ },
34
79
  supersede: SUPERSEDE_NOTE,
35
80
  },
36
81
  mcp: {
37
- title: '当前 MCP 服务器',
82
+ title: 'Current MCP servers',
38
83
  // how 只剩"怎么用备注"这半句:前半个分句「工具名是 `mcp__<服务器>__<工具>`」删掉了
39
84
  // —— 模型自己的工具表里就是这个命名(`mcp__context7__xxx`),告诉它格式是零信息量。
40
- how: '带「用户提示:」的行是用户写给这台服务器的决策提示,选服务器之前先看一眼。',
85
+ //
86
+ // 2026-10-18 补两句(用户同一轮裁定:三个点了工具的域都要有触发条件):
87
+ // ① **什么时候想起这张表** —— 模型看不到 server 的连线状态,缺工具时它只会两种反应:
88
+ // 说"没有这个工具",或反复重试。这一域存在的意义就是给它第三种(表里有、只是没连上)。
89
+ // ② `not connected now` 那一档的读法 —— 不写清"此刻没有注册任何工具",这一行会被读成
90
+ // "可用但状态存疑",于是它照旧去调一个必然失败的工具名。
91
+ //
92
+ // 同轮二次修正(用户:「mcp 如果用户没写备注不是白写了吗」):两句**都改成按需发**。
93
+ // 上一版是常量串,于是"一台上线服务器、没有备注"这种最常见的情形里,整段 = 三行说明
94
+ // 解释一个不存在的东西 + 一行 `- **github**` —— 说明比内容长,且每轮都付。
95
+ // 现在:备注句只在真有备注时发,离线句只在真有 `not connected now` 档时发,两者都没有
96
+ // 就**不发行**(`undefined`)。正文只有名字时,标题加名字本身已经把话说完了。
97
+ how: ({ text }) => {
98
+ const parts = [];
99
+ if (text.includes('user hint: ')) {
100
+ parts.push("`user hint:` marks the user's own note on that server — read it before choosing one.");
101
+ }
102
+ const offline = text.includes('(not connected now)');
103
+ const muted = text.includes('(no tools enabled here)');
104
+ // 两个状态标记合起来讲一次:各写一句会把"别当成缺失"重复两遍(2026-10-18 审查收口)。
105
+ if (offline && muted) {
106
+ parts.push('Status markers: `not connected now` = no tools registered right now; `no tools enabled here` = up, but every tool is switched off. Neither means the server is missing.');
107
+ }
108
+ else if (offline) {
109
+ parts.push('A server marked `not connected now` registers no tools right now: check this list before telling the user a server is missing or a tool does not exist.');
110
+ }
111
+ else if (muted) {
112
+ parts.push('A server marked `no tools enabled here` is up, but every one of its tools is switched off — do not report it as missing.');
113
+ }
114
+ return parts.length > 0 ? parts.join(' ') : undefined;
115
+ },
41
116
  supersede: SUPERSEDE_NOTE,
42
117
  },
43
118
  skills: {
44
- title: '当前技能目录',
119
+ title: 'Current skills',
45
120
  // 两句都只在预设没挂官方 `skill` 工具时出现,差别只在点名不点名那个取正文的工具
46
121
  // (工具被用户在兼容页关掉时不点名 —— 点名一个模型手里没有的工具只会让它去猜名字)。
47
- how: (toolHidden) => toolHidden('skill_manager_read')
48
- ? '本预设没有官方 `skill` 工具:目录只有摘要,读完再照做。'
49
- : '本预设没有官方 `skill` 工具:要正文用 `skill_manager_read`(按名字直接给正文与路径);目录只有摘要,读完再照做。',
50
- supersede: `${SUPERSEDE_NOTE.slice(0, -1)};只列当前可调用的技能。`,
122
+ //
123
+ // 2026-10-18 补「什么时候动手」这半句(用户对比官方 skill catalog 后提出模板太单薄):
124
+ // 上一版只有"读完再用"(**已经决定用之后**的纪律),没有"任务对得上就去读"(**怎么想起它**)。
125
+ // 官方那条行两者都有(`if … the task clearly matches … call the skill tool … before taking
126
+ // task actions`),而这一域恰恰只在官方那条行**缺席**时才注入 —— 也就是模型手上既没有
127
+ // `skill` 工具、也没人告诉过它技能的调用时机,缺的正是这一句。同理补上"描述不是指令"
128
+ // (官方对应句 `do not infer or follow a skill's instructions until it has been loaded`),
129
+ // 免得它拿 500 字摘要当正文照做。
130
+ // 不点名工具那一支改说"没有装载入口":上一版在那里仍写着"read a skill's body before
131
+ // following it",等于让模型去读一份它根本读不到的东西。
132
+ //
133
+ // 首句定义(用户 2026-10-18 第二轮裁定"加"):官方那条行以 `A skill is a reusable set of
134
+ // task-specific instructions.` 开头 —— 这句不是背景介绍,是后面两半句的**前提**:不懂"skill
135
+ // 是按需加载的任务指令集"时,"任务对得上就读正文"读不出任何动作,只会被理解成"一个名字 +
136
+ // 一句简介"。所以这里破例把定义放在动作句之前(正常纪律是先说要做什么,见 `SUPERSEDE_NOTE`
137
+ // 那段注释);措辞照抄官方,模型对它有现成先验。**只在官方载体缺席时发**(本域的唯一条件),
138
+ // 官方在时整段不注入,不存在两份定义。
139
+ how: ({ toolHidden }) => toolHidden('skill_manager_read')
140
+ ? 'A skill is a reusable set of task-specific instructions. No official `skill` tool and no skill reader here: names and summaries only, and no way to load a body — treat them as a capability list, not as instructions.'
141
+ : 'A skill is a reusable set of task-specific instructions. No official `skill` tool here: when a task clearly matches one or the user names one, read its body with `skill_manager_read` before acting. Summaries only — never treat a description as instructions.',
142
+ // 英文下不能再靠 `slice(0, -1)` 拼句(那会把句末句点削掉,接出
143
+ // `…in this session Only skills…`):整句照发,续句自带首字母大写。
144
+ // 边界陈述 2026-10-18 拆出来并改口:原来写 `Only currently callable skills are listed.`,
145
+ // 而取正文的工具被关掉时**一条都调不到**,那句话在那个分支里是假的。改成 `enabled`
146
+ // —— 两支都成立(它们确实都是"启用中"的),也不再依赖工具在不在手里。
147
+ supersede: SUPERSEDE_NOTE,
148
+ boundary: 'Only enabled skills are listed.',
51
149
  },
52
150
  subagents: {
53
- title: '当前子智能体',
151
+ title: 'Current subagents',
54
152
  // 分界规则(2026-09-17 方案 C,本机实测 session-ee722e23 逼出来的):官方那两个
55
153
  // 委派工具(`subagent` / `subagent_fork`)不带人设,而此前没有任何一句话说明何时该
56
154
  // 用谁 —— 模型在"审查刚读过的 README"时选了 `subagent_fork`(fork 能继承已读内容、
@@ -62,32 +160,51 @@ export const DOMAIN_FRAME = {
62
160
  // Modes 段与 `inherit` 参数说明里**各有一份**,三份都在每轮上下文里 = 同一件事付三次
63
161
  // token。留这两份(它们就在"要传这个参数"的那一刻),这里只留 how 行独有的那半句 ——
64
162
  // 与官方两个委派工具的分界规则。
65
- how: (toolHidden) => toolHidden('subagent_manager_run')
66
- ? '本会话没有带人设的委派工具;官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。'
67
- : '贴合人设的任务一律用 `subagent_manager_run`;官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。',
163
+ // 2026-10-18 补触发条件(同上):此前只说了"带人设的活走哪条通道"(选哪条),
164
+ // 没说"什么算带人设的活"(要不要委派)。**例子刻意不写**——`subagent_manager_run` 的描述里
165
+ // 已经有 "a review, an investigation, a piece of writing 且细节不必占自己的上下文",两句写在同一份
166
+ // 上下文里就是同一件事付两次。
167
+ // 不点名那一支补"只能参考、调不到":上一版只说官方那两个不带人设,而"目录里有人设 ≠
168
+ // 能委派"这层没人说 —— 模型会拿官方工具硬塞一个人设名进去。
169
+ how: ({ toolHidden }) => toolHidden('subagent_manager_run')
170
+ ? 'No persona-carrying delegation tool here; official `subagent` / `subagent_fork` carry no persona — use them only when none fits or for background work. The personas listed here are reference only, not callable.'
171
+ : 'Persona work goes through `subagent_manager_run` — when a task matches one of these personas, delegate it there. Official `subagent` / `subagent_fork` carry no persona; use them only when none fits or for background work.',
68
172
  supersede: SUPERSEDE_NOTE,
69
173
  },
70
174
  prompt: {
71
- title: '当前提示词',
175
+ title: 'Current prompt',
176
+ // 2026-10-18(审查 E):这一域此前只有 `Instructions from: <path>` 一行文件头,没有一句话
177
+ // 说明**这些规则是要遵循的**。记忆域有授权语、场景域有定义,唯独效力最强的这一域空着 ——
178
+ // 而官方那条行给的是**降权**措辞("可作参考"),我们当初刻意没抄,于是等于什么都没说。
179
+ // 现在补一句正面的:要遵循,且用户当场说的优先。
180
+ // 措辞用 "right now" 不用 "for this machine"(2026-10-18 审查):场景预设接管时,正文是
181
+ // 场景绑定的那份 AGENTS.md,不是机器全局的 —— "for this machine" 在那个态下是假话。
182
+ // "right now" 同时覆盖全局 AGENTS.md 与场景预设两种来源,由正文首行 `Instructions from:`
183
+ // 给出具体出处。
184
+ how: 'These are the instructions in force right now; follow them unless the user says otherwise in this conversation.',
72
185
  supersede: SUPERSEDE_NOTE,
73
186
  },
74
187
  };
75
188
  /**
76
- * `how` 行里**点名过的工具**(六个域里只有 skills / subagents 两个会点名)。
189
+ * `how` 行里**点名过的工具**(skills / subagents / memory 三个域会点名)。
77
190
  *
78
191
  * 为什么要有这份名单:一条 `how` 行是否成立,取决于它点名的工具在**这个 agent 手里**还在不在。
79
- * 名单放在这里而不是调用方 —— 与上面那两条 `how` 相邻,改一处不会漏另一处。
192
+ * 名单放在这里而不是调用方 —— 与上面那几条 `how` 相邻,改一处不会漏另一处。
80
193
  *
81
194
  * "不在手里"有两条路,都要算:
82
195
  * ① 兼容页「模型工具表」把它关掉(用户全局开关,名单在 index.ts 的 `hiddenTools` 里);
83
196
  * ② **人设的工具限制**把它砍掉(`decideToolFilter` → 官方 `tools.restrict`,只作用于那份
84
197
  * 人设启动的子代理)。②此前没人管:目录照注、`how` 行照样点名一个模型手里没有的工具,
85
198
  * 等于诱导它去调一个不存在的名字(2026-10-07 修)。
199
+ *
200
+ * `memory_manager_list` 2026-10-18 加入(审查 #7):记忆段截断折叠行不点名查询工具时,
201
+ * 被截掉的条目无名、模型无法发现它们。`how` 在折叠行出现时点名它,与技能 / 子智能体 /
202
+ * MCP 三个域的溢出行同构。
86
203
  */
87
- export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
204
+ export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run', 'memory_manager_list'];
88
205
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
89
206
  export const fallbackFrame = (label) => ({
90
- title: `当前的${label}`,
207
+ title: `Current ${label}`,
91
208
  supersede: SUPERSEDE_NOTE,
92
209
  });
93
210
  export const domainFrame = (key, label) => DOMAIN_FRAME[key] ?? fallbackFrame(label);
@@ -131,34 +248,56 @@ export function escapeFrameBody(body) {
131
248
  * 同时把**正文也包进标记里**(此前只有引导语在标记内、正文裸奔)—— 官方两条注入行都是
132
249
  * 整条包住的,正文里的用户自由文本更需要这层来源标记。
133
250
  */
134
- export function renderDomainText(section, toolHidden = () => false) {
251
+ export function renderDomainText(section, toolHidden = () => false, replacing = true) {
135
252
  const frame = domainFrame(section.key, section.label);
136
253
  // 层级(2026-09-23 用户看到实际注入后指出「记忆内的场景怎么都是 ## 标题」):**`#` 一级给板块**
137
254
  // (场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词),域正文里的 `##` 才是它的下一层
138
- // (记忆段的 `## 场景:X`、超预算时的 `## 未注入的参考信息`)。此前标题也是 `##`,两者平级,
255
+ // (记忆段的 `## Scene: X`、超预算时的 `## Not injected (…)`)。此前标题也是 `##`,两者平级,
139
256
  // 模型读不出主次 —— 而"哪些内容归在哪个板块/场景下"正是它做判断时要用的结构。
140
257
  //
141
258
  // 开标签后**必须空一行**:markdown 里 `#` 紧跟在一行文字后面只是**段落续行**,不会被渲染成
142
259
  // 标题 —— 用户截图里 `## 本机当前的场景` 就是这么被吞掉的(和 `<system-reminder>` 挤成一段)。
143
260
  // 收尾同理:正文末尾先归一成单个空行,免得 `</system-reminder>` 粘在最后一行上。
144
- // 结构:标题 → 补充说明(有才发)→ 权威声明 → 正文。没有 cue 那一行(见 `DomainFrame` 的注释)。
261
+ // 结构:标题 → 补充说明(有才发)→ 权威声明(只在替换时)→ 边界陈述(有才发)→ 正文。
262
+ // 没有 cue 那一行(见 `DomainFrame` 的注释)。
263
+ const info = { toolHidden, text: section.text, replacing };
145
264
  const lines = [FRAME_OPEN, '', `# ${frame.title}`];
146
- const how = typeof frame.how === 'function' ? frame.how(toolHidden) : frame.how;
265
+ const how = typeof frame.how === 'function' ? frame.how(info) : frame.how;
147
266
  if (how !== undefined)
148
267
  lines.push(how);
149
- lines.push(frame.supersede, '', escapeFrameBody(section.text).replace(/\n+$/, ''), '', FRAME_CLOSE);
268
+ // 权威声明只在**替换**时发(2026-10-18 审查 D):本会话的第一份没有旧内容可取代,那句话
269
+ // 只是占一行。边界陈述反过来 —— 它不依赖"有没有旧的",第一份就要发。
270
+ if (replacing)
271
+ lines.push(frame.supersede);
272
+ if (frame.boundary !== undefined)
273
+ lines.push(frame.boundary);
274
+ lines.push('', escapeFrameBody(section.text).replace(/\n+$/, ''), '', FRAME_CLOSE);
150
275
  return lines.join('\n');
151
276
  }
152
277
  /** 「已清空」通知正文:某个域曾经注入过、现在没有内容时发一条(纯函数,测试用)。 */
153
- export function clearedDomainText(key, label) {
278
+ export function clearedDomainText(key, label, reason) {
154
279
  const frame = domainFrame(key, label);
280
+ // 通知的措辞按**原因**分叉(2026-10-18 审查)。原来只有一句「同一类内容不再有效」,
281
+ // 而"这一域为什么消失"有四种,其中一种下那句话是**假话**:
282
+ // - `official`:官方载体接管了(技能目录由 `dsh-tool-skill` 送、提示词由
283
+ // `dsh-agent-instructions` 送)。此时同类内容**仍然有效、而且更强**,只是换了个发送方。
284
+ // 说"不再有效"会让模型把官方那一份也当成作废 —— 标准预设里这正是最常见的一次切换。
285
+ // - `empty`:真的没有内容了(全部删掉 / 场景切走)。原来的话在这里是对的。
286
+ // - `off` / `child`:是**故意不送**(用户关掉这一域,或该域不适用于子会话),内容本身
287
+ // 可能还在,所以只能说"不再展示",不能说"不存在"。
288
+ // - `error` 走不到这里:那条路径刻意不发「已清空」(见调用方注释)。
289
+ const body = reason === 'official'
290
+ ? '**Replaced** — the official channel now sends this content; earlier injected content of the same kind is stale.'
291
+ : reason === 'off' || reason === 'child'
292
+ ? '**Cleared** — this reminder is not sent here; earlier injected content of the same kind no longer applies.'
293
+ : '**Cleared** — earlier injected content of the same kind no longer applies.';
155
294
  // 形状与 `renderDomainText` 一致(开标签后空行、板块 `#`、收尾空行)—— 这两条都是同一个
156
295
  // 通道发出去的消息,层级与留白不该有两套。
157
296
  return [
158
297
  FRAME_OPEN,
159
298
  '',
160
299
  `# ${frame.title}`,
161
- '**已清空** —— 本次会话中此前注入的同类内容不再有效。',
300
+ body,
162
301
  '',
163
302
  FRAME_CLOSE,
164
303
  ].join('\n');
@@ -40,7 +40,8 @@
40
40
  // 判定放在域声明的 `applicableTo` 上,通道只负责问一句 —— "哪个域对哪类会话不成立"
41
41
  // 是域的语义,不是通道的机制。
42
42
  // ⚠️ 2026-09-17 方案 A 纠正:这**不是**"子会话不能委派"。官方 `dsh-tool-subagent`
43
- // 默认 `maxDepth: 3`,provider 只在传了该值时才校验 —— 子代理本来就能继续嵌套
43
+ // 默认 `maxDepth: 1`(0.2.1 实测核对:`dsh-subagent/lib/index.js` 的 Config 默认值),
44
+ // provider 只在传了该值时才校验 —— 子代理本来就能继续嵌套
44
45
  // (用户实测确认)。这个抑制只是让常驻目录不进入子会话、减少噪声;委派能力由官方
45
46
  // 决定,本插件不干预(我们也不再传 `maxDepth`)。
46
47
  // - **采纳遥测**:注入只解决"到没到",不解决"用没用"。本模块按域统计
@@ -280,7 +281,7 @@ export function createContextInjector(deps) {
280
281
  // 的可见提示由兼容页那条「去填令牌」横幅承担。
281
282
  try {
282
283
  if (deps.tokenGateActive && deps.tokenGateActive()) {
283
- log('令牌未验证:本轮对话被拒绝(在「工具 → 兼容」页填入访问令牌后恢复)');
284
+ log('令牌未验证:本轮对话被拒绝(在「工具管理 → 兼容」页填入访问令牌后恢复)');
284
285
  return { kind: 'reject' };
285
286
  }
286
287
  }
@@ -376,12 +377,14 @@ export function createContextInjector(deps) {
376
377
  const toolHidden = hiddenNow === null ? () => false : (name) => hiddenNow.has(name);
377
378
  for (const section of sections) {
378
379
  published.add(section.key);
379
- const text = renderDomainText(section, toolHidden);
380
- const current = visible.get(section.key);
381
- if (current !== undefined && current.text === text)
382
- continue;
383
380
  // `current !== undefined` = 该域在可见表面上已有一条更早的己方消息 → 这次是**替换**,
384
381
  // 界面上的动作标签据此从「已载入」变成「已更新」(见 domainMessage 的 baseline/changes)。
382
+ // 这个判据同时决定框架里那句 `Supersedes …` 发不发(2026-10-18:第一份没有旧内容可取代)。
383
+ const current = visible.get(section.key);
384
+ const replacing = current !== undefined;
385
+ const text = renderDomainText(section, toolHidden, replacing);
386
+ if (replacing && current.text === text)
387
+ continue;
385
388
  let files;
386
389
  try {
387
390
  files = domainOf.get(section.key)?.files?.();
@@ -389,7 +392,7 @@ export function createContextInjector(deps) {
389
392
  catch {
390
393
  files = undefined;
391
394
  }
392
- additions.push(domainMessage(section, text, files, current !== undefined));
395
+ additions.push(domainMessage(section, text, files, replacing));
393
396
  appended.push(section.key);
394
397
  injectedKeys.add(section.key);
395
398
  }
@@ -409,7 +412,7 @@ export function createContextInjector(deps) {
409
412
  const label = labelOf.get(key) ?? key;
410
413
  if (previous.form === 'notice')
411
414
  continue;
412
- additions.push(clearedMessage(key, label));
415
+ additions.push(clearedMessage(key, label, lastReasons[key]));
413
416
  appended.push(key);
414
417
  clearedKeys.add(key);
415
418
  }
@@ -524,9 +527,9 @@ function domainMessage(section, text, files, replacing) {
524
527
  source,
525
528
  });
526
529
  }
527
- function clearedMessage(key, label) {
530
+ function clearedMessage(key, label, reason) {
528
531
  return createUserMessage({
529
- content: [{ type: 'text', text: clearedDomainText(key, label) }],
532
+ content: [{ type: 'text', text: clearedDomainText(key, label, reason) }],
530
533
  source: { kind: INJECT_KIND_OF[key], form: 'notice', summary: `${label}已清空` },
531
534
  });
532
535
  }
package/lib/http-api.js CHANGED
@@ -46,9 +46,10 @@ export function installHttpApi(deps) {
46
46
  req.on('error', reject);
47
47
  });
48
48
  // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
49
- // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
49
+ // 没有全局中间件,既定约定是每个注册方自己调 connection.admit(req):
50
50
  // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
51
- // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
51
+ // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权,
52
+ // 并交回这次请求所代表的 Peer(本路由是 exact 路由,只服务 owner Peer)。
52
53
  // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
53
54
  // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
54
55
  const connection = (() => {
package/lib/http-fence.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // 为什么需要它:宿主的 BrowserAuth 栅栏**只包在宿主自己注册的路由上**
4
4
  // (dsh-client-connection 的 register()/upgrade、dsh-api-gateway、
5
5
  // dsh-host-open-in-app),dsh-host-webserver 本身没有全局中间件。宿主生态的
6
- // 既定约定是每个注册方自己调 `connection.requestRejection(req)`,它做两件事:
6
+ // 既定约定是每个注册方自己调 `connection.admit(req)`(0.2.1 起的完整版,
7
+ // `requestRejection(req)` 是它只取状态码的那层包装),它做两件事:
7
8
  //
8
9
  // 1. Host/Origin 栅栏 —— Host 必须是回环地址、deployment 派生的 LAN IP 字面量
9
10
  // 或声明的 trustedHosts。Host 是 DNS rebinding 唯一伪造不了的头:攻击者
@@ -11,6 +12,9 @@
11
12
  // (origin.host === host)会同时成立,于是被误判为同源。
12
13
  // 2. browser-session cookie 鉴权(HttpOnly + SameSite=Strict,Path=/)。
13
14
  //
15
+ // `admit()` 还交回这次请求所代表的 **Peer**,而本插件的 exact 路由按官方契约只服务
16
+ // owner Peer(非 owner 一律 403),所以 principal 也要验 —— 只取状态码会漏掉这一层。
17
+ //
14
18
  // 只查公开常量 `x-dsh-plugin` 与自比对 Origin 等于没鉴权:本机任意进程都能调写 op,
15
19
  // 而写 op 里包含 `mcpm-add`(command/args 无白名单,宿主 stdio 传输会 spawn 它
16
20
  // → 任意命令执行)与 `history-delete`(永久删除会话)。
@@ -33,6 +37,27 @@ const headerValue = (req, name) => {
33
37
  */
34
38
  export function fenceRejection(req, connection) {
35
39
  try {
40
+ // 优先 `admit()`(0.2.1 起存在):它做 Host/Origin 栅栏 + 浏览器 session 鉴权,
41
+ // 并把"这次请求代表谁"作为 Peer 交出来。`requestRejection()` 只给状态码 —— 状态码
42
+ // 足以回答"拦不拦",却不足以回答"这个调用方被授权到什么程度",而本插件的路由是
43
+ // **exact 路由**(`kind: 'exact'`,官方契约:只有 owner Peer 才该拿到响应)。
44
+ // 没有 principal 信息时只能当作非 owner 处理 —— 这正是下面 403 那一支的理由。
45
+ const admission = connection?.admit?.(req);
46
+ if (admission !== undefined) {
47
+ if ('rejection' in admission) {
48
+ const status = admission.rejection;
49
+ return {
50
+ status,
51
+ error: status === 401
52
+ ? '未通过宿主的浏览器鉴权:请在 DSH Web GUI 内操作,或为本插件配置访问令牌(config.token / DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)'
53
+ : 'Host/Origin 栅栏拒绝:该请求不是发往本机地址的同源请求',
54
+ };
55
+ }
56
+ if (admission.peer?.principal?.kind !== 'owner') {
57
+ return { status: 403, error: '拒绝:调用方不是本机所有者身份' };
58
+ }
59
+ return null;
60
+ }
36
61
  const rejection = connection?.requestRejection?.(req);
37
62
  if (typeof rejection === 'number') {
38
63
  return {
package/lib/index.js CHANGED
@@ -398,14 +398,19 @@ export default {
398
398
  // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
399
399
  currentSkills: async () => (await skillScan()).states,
400
400
  applySkills: async (target) => {
401
- // 三道过滤,缺一不可:
401
+ // 四道过滤,缺一不可:
402
402
  // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
403
403
  // 2. 结构不完整的技能也跳过——core 对它们一律拒绝启停(error.skill.notLoadable),
404
404
  // 不跳过就会抛成"应用失败",**整个场景进不去**(老档案里的残留键能踩到)。
405
- // 3. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
405
+ // 3. **已经不存在的技能也跳过**(用户 2026-10-18 报「启动的场景里有被删除的内容,
406
+ // 就导致没办法退出」):快照 / 档案里记的是"进场景那一刻还在"的键,场景期间那条
407
+ // 技能被删掉之后,退出时按快照回放必然报 `技能不存在`,于是整次退出被这一条打断、
408
+ // mode 停在原地 —— 用户被困在场景里。没有可写的对象,跳过才是对的。
409
+ // 4. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
406
410
  // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
407
411
  // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
408
412
  const { states, shadowed, unloadable } = await skillScan();
413
+ const skipped = [];
409
414
  for (const [key, on] of Object.entries(target)) {
410
415
  const i = key.indexOf('/');
411
416
  if (i <= 0)
@@ -417,13 +422,29 @@ export default {
417
422
  // 继续往下走会抛成"应用失败",**整个场景就进不去了**(老档案里的残留键能踩到)。
418
423
  if (unloadable.has(key))
419
424
  continue;
425
+ // 这次扫描里根本没有这个键 = 技能已被删除(或所在目录已消失):没有可写的对象。
426
+ if (!(key in states)) {
427
+ skipped.push(`${key}(已不存在)`);
428
+ continue;
429
+ }
420
430
  if (states[key] === on)
421
431
  continue;
422
432
  const root = key.slice(0, i);
423
433
  const name = key.slice(i + 1);
424
434
  const r = await skillsService.ops[on ? 'skill-enable' : 'skill-disable']({ root, name });
425
- if (r && r.ok === false)
435
+ if (r && r.ok === false) {
436
+ // 结构性失败(这一条现在解不开)只记警告并跳过 —— 与 applySkillSourceSwitches
437
+ // 同一条口径:拿它中断整个进入 / 退出只会把用户困在场景里,而这一行与本次改动无关。
438
+ const code = String((r && r.code) || '');
439
+ if (code === 'error.skill.notFound' || code === 'error.skill.notLoadable' || code === 'error.skill.noFrontmatter') {
440
+ skipped.push(`${key}(${r.error})`);
441
+ continue;
442
+ }
426
443
  throw new Error(`技能 ${key} 应用失败: ${r.error}`);
444
+ }
445
+ }
446
+ if (skipped.length) {
447
+ ctx.logger?.warn?.(`scene: 跳过无法应用的技能 —— ${skipped.join('、')}`);
427
448
  }
428
449
  },
429
450
  // ── 服务器级 / 来源级启停(P5)────────────────────────────────────────────
@@ -705,7 +726,7 @@ export default {
705
726
  // 三份界面设置侧车(inject / scene / slash)拆到 ./settings-sidecars.ts:读走 3 秒 TTL 缓存,
706
727
  // 写共用上面那条串行写锁。工厂只在这里调用一次,三份缓存仍是「每次 apply 一份」。
707
728
  // 传的是 apply 作用域里那几个函数声明的引用(提升已初始化),不是快照值。
708
- const { readInjectSettings, injectSettingsSync, injectNoticeOptions, injectSettingsOp, readSceneSettings, sceneSettingsOp, readSlashSettings, slashSettingsOp, } = createSettingsSidecars({
729
+ const { readInjectSettings, injectSettingsSync, injectNoticeOptions, injectSettingsOp, readSceneSettings, sceneSettingsOp, readSlashSettings, slashSettingsOp, readSidebarSettings, sidebarSettingsOp, } = createSettingsSidecars({
709
730
  ensurePaths,
710
731
  readJsonFile,
711
732
  writeJsonFile,
@@ -727,7 +748,7 @@ export default {
727
748
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
728
749
  //
729
750
  // 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
730
- // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider 只在
751
+ // 委派"。委派可行性由官方决定:`dsh-subagent` 默认 `maxDepth: 1`(0.2.1 实测核对),provider 只在
731
752
  // 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
732
753
  // 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
733
754
  // · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
@@ -749,7 +770,7 @@ export default {
749
770
  { key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
750
771
  // 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
751
772
  { key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
752
- { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
773
+ { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: (agent) => mcp.stateCatalog.text(agent) },
753
774
  { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: (agent) => skillCatalog.text(agent) },
754
775
  {
755
776
  key: 'subagents',
@@ -769,7 +790,7 @@ export default {
769
790
  // 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
770
791
  // `blocked`。这是"没输入令牌就没法对话"的唯一真正落实 —— 客户端那条输入框锁定在
771
792
  // 本宿主上无路可走(`ctx.conversation.blocks` 不存在,见 ContextInjectorDeps 注释)。
772
- // 只读 op 不受影响,所以「工具 → 兼容」页照常能打开、能填令牌 —— 解铃就在那里。
793
+ // 只读 op 不受影响,所以「工具管理 → 兼容」页照常能打开、能填令牌 —— 解铃就在那里。
773
794
  tokenGateActive: () => TOKEN !== '' && !acceptedThisBoot(),
774
795
  });
775
796
  contextInjectorLive = () => injector.live();
@@ -1395,6 +1416,9 @@ export default {
1395
1416
  pluginHiddenTools: () => [...toolTableHidden()],
1396
1417
  // 有官方等价物时逐 agent 让位(见上面 CARRIER_DUPLICATES 的注释)。
1397
1418
  carrierHiddenTools: carrierHiddenToolsFor,
1419
+ // 状态段尾句那句「用 `mcp_manager_list` 查」的前提:与技能 / 子智能体两个目录同一份判据
1420
+ // (2026-10-18 审查 B)。
1421
+ listToolVisible: (agent) => toolVisibleFor('mcp_manager_list', agent),
1398
1422
  });
1399
1423
  // ---------- ops ----------
1400
1424
  async function pluginVersion() {
@@ -1672,6 +1696,9 @@ export default {
1672
1696
  // 斜杠命令入口的开关(读 / 写):客户端 boot 时读一次决定挂不挂 `/` 菜单里的「工具」段,
1673
1697
  // 兼容页那两颗开关写一次。同 `scene-settings`:按写门禁但不冻结(见 slash-settings.ts)。
1674
1698
  'slash-settings': (args) => slashSettingsOp(args),
1699
+ // 侧栏入口的开关(读 / 写):客户端 boot 时读一次决定注不注册侧栏那一行,兼容页那颗
1700
+ // 按钮写一次。同 `slash-settings`:按写门禁但不冻结(见 sidebar-settings.ts)。
1701
+ 'sidebar-settings': (args) => sidebarSettingsOp(args),
1675
1702
  // state-doctor:跨域**悬空引用**体检(只读,实现与理由见 src/ops/state-doctor.ts)。
1676
1703
  // 这里只做"把权威集合取来"这一件事 —— 某一域读失败就传 null,体检会把它记进
1677
1704
  // `skipped` 而不是当成"该域没有悬空项"(读不到 ≠ 不存在,报成后者就是骗人)。
@@ -113,7 +113,12 @@ export function createMcpManager(deps) {
113
113
  const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
114
114
  // MCP 状态段(注入通道的数据源)由本域自己建:它要的 rows 就是本域的 mcpmRowsWithNotes(false),
115
115
  // 让 index.ts 建再传进来会形成 index ↔ manager 的循环引用。
116
- const mcpStateCatalog = createMcpStateCatalog({ rows: () => mcpmRowsWithNotes(false) });
116
+ // `listToolVisible` 是唯一从外面进来的:它要看**这个 agent** 手里有没有 `mcp_manager_list`
117
+ // (兼容页开关 + 人设 toolsDeny),判据在 index.ts 的 `toolVisibleFor`,不在这里复制一份。
118
+ const mcpStateCatalog = createMcpStateCatalog({
119
+ rows: () => mcpmRowsWithNotes(false),
120
+ ...(deps.listToolVisible === undefined ? {} : { listToolVisible: deps.listToolVisible }),
121
+ });
117
122
  // ---------- duplicate loader-id guard ----------
118
123
  // Two rows with the same loader id make the plugin composition fail to
119
124
  // boot (upstream hit exactly this after renaming an entry), so duplicates