dsh-plugin-tool-management 0.16.6 → 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 (96) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +20 -4
  3. package/README_EN.md +20 -4
  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 +59 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +1615 -148
  23. package/lib/compat/capability-specs.js +321 -0
  24. package/lib/compat/host-probe.js +261 -0
  25. package/lib/compat/host-roots.js +168 -0
  26. package/lib/compat/probe-types.js +6 -0
  27. package/lib/compat/probe.js +54 -714
  28. package/lib/context-inject-contract.js +114 -0
  29. package/lib/context-inject-frame.js +147 -0
  30. package/lib/context-inject-select.js +127 -0
  31. package/lib/context-inject-surface.js +97 -0
  32. package/lib/context-inject.js +57 -459
  33. package/lib/host-names.js +150 -0
  34. package/lib/http-api.js +229 -0
  35. package/lib/hub.js +84 -11
  36. package/lib/imports/parsers.js +40 -15
  37. package/lib/index.js +235 -843
  38. package/lib/mcp/inspect-probe.js +199 -0
  39. package/lib/mcp/loader-token.js +26 -0
  40. package/lib/mcp/manager.js +267 -558
  41. package/lib/mcp/patch-yaml.js +207 -40
  42. package/lib/mcp/secret-guard.js +122 -16
  43. package/lib/mcp/tool-visibility.js +388 -0
  44. package/lib/memories/archive-engine.js +163 -16
  45. package/lib/memories/archive.js +10 -1
  46. package/lib/memories/discovery.js +261 -0
  47. package/lib/memories/export-plan.js +42 -0
  48. package/lib/memories/index-io.js +125 -12
  49. package/lib/memories/memories-types.js +1 -0
  50. package/lib/memories/service.js +167 -78
  51. package/lib/memories/snapshot.js +41 -260
  52. package/lib/op-registry.js +72 -8
  53. package/lib/ops/compat.js +17 -2
  54. package/lib/ops/prompts.js +18 -5
  55. package/lib/ops/quick-prompts.js +26 -0
  56. package/lib/ops/scene-records.js +157 -10
  57. package/lib/ops/scene-sync.js +71 -1
  58. package/lib/ops/sessions.js +63 -13
  59. package/lib/ops/snapshot.js +123 -24
  60. package/lib/ops/trash.js +45 -9
  61. package/lib/prompts/quick-service.js +211 -0
  62. package/lib/prompts/service.js +7 -2
  63. package/lib/request-gate.js +115 -56
  64. package/lib/scene-linkage.js +184 -0
  65. package/lib/scene-prompt-sync.js +46 -7
  66. package/lib/scenes/candidates.js +21 -3
  67. package/lib/sessions/archive-schemas.js +121 -0
  68. package/lib/sessions/path-key.js +46 -0
  69. package/lib/sessions/workspace.js +45 -168
  70. package/lib/settings-sidecars.js +143 -0
  71. package/lib/skills/core-types.js +8 -0
  72. package/lib/skills/core-util.js +81 -0
  73. package/lib/skills/core.js +97 -2297
  74. package/lib/skills/dir-watch.js +71 -0
  75. package/lib/skills/frontmatter.js +96 -0
  76. package/lib/skills/homes.js +14 -0
  77. package/lib/skills/import-pipeline.js +231 -0
  78. package/lib/skills/manager-state.js +800 -0
  79. package/lib/skills/official.js +269 -0
  80. package/lib/skills/provider-registration.js +89 -0
  81. package/lib/skills/service.js +4 -234
  82. package/lib/skills/skill-roots.js +287 -0
  83. package/lib/skills/skill-trash.js +265 -0
  84. package/lib/skills/system-trash.js +97 -0
  85. package/lib/skills/upload-pipeline.js +374 -0
  86. package/lib/slash-settings.js +25 -0
  87. package/lib/subagents/persona-parse.js +312 -0
  88. package/lib/subagents/persona-types.js +5 -0
  89. package/lib/subagents/persona-write.js +93 -0
  90. package/lib/subagents/run-provider.js +159 -0
  91. package/lib/subagents/service.js +20 -639
  92. package/lib/subagents/tool-filter.js +98 -0
  93. package/lib/token-config.js +240 -0
  94. package/lib/tool-table-switch.js +163 -0
  95. package/lib/tools/scene.js +40 -9
  96. package/package.json +3 -2
@@ -4,18 +4,19 @@
4
4
  // * 访问令牌(可选)—— 两条用途:宿主浏览器鉴权的逃生口,以及写操作的纵深防御;
5
5
  // * 写 / 敏感 op 白名单 —— 哪些 op 要凭令牌(含"只读但会泄露明文"与"只读但会改宿主状态");
6
6
  // 清单本身在 `./op-registry.ts`,本文件只拼装(`opsWith` / `frozenOps`)。
7
- // * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结;
7
+ // * 场景锁定 —— 任一场景 locked=true 时五个管理域整体冻结。判据是 `createFrozenGate`,
8
+ // HTTP handlers 与**模型侧 op 表**各装一次,用的是同一份函数;
8
9
  // * handlers 后处理 —— 把上面三条装到 op 表上(包装顺序即语义,见 installHandlerGuards)。
9
10
  //
10
11
  // 为什么抽出来:四段分散在 apply() 的 220 / 902 / 1650 / 1928,靠闭包共享 handlers 与
11
12
  // memoriesService。抽成工厂后依赖改为显式入参,index.ts 只剩四行装配 —— 而引用点用解构
12
13
  // 保持原名字,一行没动(改名才是这类搬迁最容易出静默错误的地方)。
13
14
  //
14
- // **一处必须守住的不变量**:handlers 的后处理顺序。`guardLockedOps` / `syncSceneArchiveOnSwitch`
15
+ // **一处必须守住的不变量**:handlers 的后处理顺序。冻结包装 / `syncSceneArchiveOnSwitch`
15
16
  // 都是「读原函数 → 换包装」的原地改写,先装的在内层、后装的在外层;`annotateLocked` 只挂在
16
17
  // 五个读 op 上。整段照搬、顺序未变。
17
18
  import { createHash, timingSafeEqual } from 'node:crypto';
18
- import { frozenOps, opsWith } from './op-registry.js';
19
+ import { OP_REGISTRY, frozenOps, opsWith } from './op-registry.js';
19
20
  /**
20
21
  * 建访问令牌三件套。**必须**从 apply 的第二个参数取配置 ——
21
22
  * 读 `ctx.config` 不是注入服务,启动时会抛 "cannot get property without inject"。
@@ -114,10 +115,13 @@ export function createOpWhitelist(deps) {
114
115
  }
115
116
  // ---------- 场景锁定守卫(v0.8)----------
116
117
  // 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
117
- // 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
118
- // 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
119
- // service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
120
- // 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
118
+ // 冻结清单里的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
119
+ //
120
+ // 装在**哪一层**:HTTP handlers 一层 + 模型侧 op 表一层(index.ts 的 `auditOps`),
121
+ // 两层用同一个判据(`createFrozenGate`)。**不装在 service.ops 上**是有意的:进/退模式的
122
+ // 运行时应用走的是内部函数与 service.ops(applyMcpServerSwitches / applySkills /
123
+ // applySubagentSwitches / patchIndex),不经过 handlers —— 锁定就是为了让场景能按原样启动,
124
+ // 运行时应用不能被自己挡住。
121
125
  // 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
122
126
  export function createSceneLock(deps) {
123
127
  async function lockedSceneNames() {
@@ -144,12 +148,104 @@ export function createSceneLock(deps) {
144
148
  }
145
149
  return { lockedSceneNames, activeSceneName, lockedSceneGuard };
146
150
  }
151
+ /** 「改它」的兜底动词(登记表里没有专门文案时用)。 */
152
+ const FROZEN_DEFAULT_VERB = '修改';
153
+ const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
154
+ const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
155
+ /**
156
+ * 场景冻结的**唯一判据**:`op` + `args` → 拒绝文案(null = 放行)。
157
+ *
158
+ * 三种口径的语义登记在 `./op-registry.ts` 的 `frozenScope` 上,这里只实现:
159
+ * all —— 只要有场景锁着就拒(不看 args)。
160
+ * active-scene —— 只在"锁着的场景正在生效"且这次提交**改了启用集合**时拒
161
+ * (同集合的重复提交放行:它没改变任何东西,不该被"冻结"挡下来)。
162
+ * self-scene —— 只挡被锁的那个场景自己(目标从 `args.scene` / `args.name` 取)。
163
+ *
164
+ * 为什么抽成一个函数:HTTP handlers 与**模型侧 op 表**(index.ts 的 `auditOps`)必须用
165
+ * 同一份判据。此前只包 handlers —— 模型工具拿的是 `service.ops`,于是"锁一个**非活动**
166
+ * 场景"在 HTTP 上被拒、在模型侧五个域全放行(审查 F3,0.17.0 修)。判据只写一遍,
167
+ * 两侧就不可能再分家。
168
+ *
169
+ * 判据只读 `args`、不落盘,所以对同一次调用可以安全地判两次(handlers 与模型侧各一次)。
170
+ */
171
+ export function createFrozenGate(deps) {
172
+ const { lockedSceneNames, activeSceneName } = deps;
173
+ return async function frozenRejection(op, args) {
174
+ const cls = OP_REGISTRY[op];
175
+ if (!cls || cls.frozen !== true)
176
+ return null;
177
+ const scope = cls.frozenScope ?? 'all';
178
+ if (scope === 'active-scene') {
179
+ const scene = await activeSceneName();
180
+ if (!scene || !(await lockedSceneNames()).includes(scene))
181
+ return null;
182
+ const current = new Set([scene]);
183
+ const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
184
+ .map((n) => String(n == null ? '' : n).trim())
185
+ .filter((n) => n !== ''));
186
+ const same = current.size === next.size && [...current].every((n) => next.has(n));
187
+ if (same)
188
+ return null;
189
+ const what = ACTIVE_SCENE_LABEL[op] ?? '改它';
190
+ return `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)`;
191
+ }
192
+ if (scope === 'self-scene') {
193
+ const scene = String((args && (args.scene || args.name)) || '').trim();
194
+ if (!scene)
195
+ return null;
196
+ if (!(await lockedSceneNames()).includes(scene))
197
+ return null;
198
+ return `场景「${scene}」已锁定:先解锁再${SELF_SCENE_LABEL[op] ?? '改它'}`;
199
+ }
200
+ const locked = await lockedSceneNames();
201
+ return locked.length ? `场景已锁定(${locked.join('、')}):先到场景页解锁再${FROZEN_DEFAULT_VERB}` : null;
202
+ };
203
+ }
204
+ // ── 3.6 模型侧 op 表的冻结闸(与 handlers 同一份判据)────────────────────────
205
+ /**
206
+ * 给**模型侧**的 op 表装冻结闸(审查 F3,0.17.0)。
207
+ *
208
+ * 为什么抽成一个函数、而不是在 index.ts 里手写 for 循环:模型侧与 HTTP 侧必须用**同一个**
209
+ * `frozen` 判据对象。写成两个循环、各自去 `createFrozenGate`,就给了"两边又分家"的机会 ——
210
+ * 而 F3 的成因正是两侧判据不同。抽出来之后,契约测试可以直接拿真实的 `createFrozenGate`
211
+ * 与真实的 `installHandlerGuards` 对同一个 op 比对两侧结论。
212
+ *
213
+ * **不改动传入的表**:返回新表。场景引擎直调的是原表(`applySkills` → `service.ops`),
214
+ * 装在原表上会把场景自己锁死。
215
+ *
216
+ * @param ops 原始 op 表
217
+ * @param frozen `createFrozenGate(...)` 的返回值
218
+ * @param after 成功后的回调(挂流水用)。被拒时**不调用** —— 什么都没改。
219
+ */
220
+ export function guardModelOps(ops, frozen, after) {
221
+ const out = {};
222
+ for (const [opName, fn] of Object.entries(ops)) {
223
+ if (typeof fn !== 'function') {
224
+ out[opName] = fn;
225
+ continue;
226
+ }
227
+ out[opName] = async (args) => {
228
+ const denied = await frozen(opName, args || {});
229
+ if (denied)
230
+ return { ok: false, error: denied };
231
+ const result = await fn(args || {});
232
+ if (after)
233
+ after(opName, args || {}, result);
234
+ return result;
235
+ };
236
+ }
237
+ return out;
238
+ }
239
+ /** 单个 op 版本(`guardModelOps` 的一元特例):给不经 op 表的调用点用。 */
240
+ export function guardModelOp(opName, fn, frozen, after) {
241
+ return guardModelOps({ [opName]: fn }, frozen, after)[opName];
242
+ }
147
243
  export function installHandlerGuards(deps) {
148
244
  const { handlers, lockedSceneNames, activeSceneName, syncSwitchToScene, subagentToolFailures } = deps;
149
245
  /**
150
246
  * 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
151
247
  * 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
152
- * 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
248
+ * 锁定由更靠内的冻结包装挡住(先装的在内层,它返回 `ok:false` 时这里不会去同步档案)。
153
249
  */
154
250
  function syncSceneArchiveOnSwitch(opNames) {
155
251
  for (const opName of opNames) {
@@ -165,64 +261,27 @@ export function installHandlerGuards(deps) {
165
261
  };
166
262
  }
167
263
  }
168
- function guardLockedOps(opNames, what) {
169
- for (const opName of opNames) {
264
+ // 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
265
+ // 三种口径(all / active-scene / self-scene)现在合成**一个**判据函数(`createFrozenGate`),
266
+ // 由 `frozenOps()`(不带 scope = 全部冻结 op)一次装完。原先它们分散成三段手写包装,
267
+ // 而模型侧 op 表压根没装 —— 这正是 F3 的成因(见 `createFrozenGate` 的注释)。
268
+ // 包装顺序不变:冻结仍在**内层**,`syncSceneArchiveOnSwitch` 在外层(前者返回 ok:false 时
269
+ // 后者不会去同步档案)。
270
+ {
271
+ const frozen = createFrozenGate({ lockedSceneNames, activeSceneName });
272
+ for (const opName of frozenOps()) {
170
273
  const original = handlers[opName];
171
274
  if (typeof original !== 'function')
172
275
  continue;
173
276
  handlers[opName] = async (args) => {
174
- const locked = await lockedSceneNames();
175
- if (locked.length)
176
- return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
177
- return original(args);
277
+ const denied = await frozen(opName, args);
278
+ return denied ? { ok: false, error: denied } : original(args);
178
279
  };
179
280
  }
180
281
  }
181
- // 冻结清单与档案同步清单都来自 `./op-registry.ts`:加 op 时在那里登记一条,这里不再抄。
182
- guardLockedOps(frozenOps('all'), '修改');
183
282
  // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
184
283
  // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
185
284
  syncSceneArchiveOnSwitch(opsWith('syncsArchive'));
186
- // 口径 `frozenScope: 'active-scene'` 的那几条(判据与理由登记在 op-registry.ts 的
187
- // `rules-set-active` 条目上)。没有锁定场景在生效时(全局态 / 场景未锁)照旧可用。
188
- const ACTIVE_SCENE_LABEL = { 'rules-set-active': '切换场景' };
189
- for (const opName of frozenOps('active-scene')) {
190
- const what = ACTIVE_SCENE_LABEL[opName] ?? '改它';
191
- const original = handlers[opName];
192
- if (typeof original !== 'function')
193
- continue;
194
- handlers[opName] = async (args) => {
195
- const scene = await activeSceneName();
196
- if (scene && (await lockedSceneNames()).includes(scene)) {
197
- // 同集合的重复提交放行:它没有改变任何东西,不该被"冻结"挡下来。
198
- const current = new Set([scene]);
199
- const next = new Set((Array.isArray(args && args.scenes) ? args.scenes : [])
200
- .map((n) => String(n == null ? '' : n).trim())
201
- .filter((n) => n !== ''));
202
- const same = current.size === next.size && [...current].every((n) => next.has(n));
203
- if (!same) {
204
- return { ok: false, error: `场景「${scene}」已锁定:先到场景页解锁再${what}(锁定期间改启用集合会让模型侧的写门禁失效)` };
205
- }
206
- }
207
- return original(args);
208
- };
209
- }
210
- // 被锁场景自身的档案与删除:只挡它自己,别的场景照常(口径 `frozenScope: 'self-scene'`)。
211
- // 目标场景一律从 `args.scene` / `args.name` 里取 —— 这两个键名就是本插件 op 的惯例。
212
- const SELF_SCENE_LABEL = { 'scene-archive-save': '改档案', 'rules-remove-scene': '删除' };
213
- for (const opName of frozenOps('self-scene')) {
214
- const what = SELF_SCENE_LABEL[opName] ?? '改它';
215
- const original = handlers[opName];
216
- if (typeof original !== 'function')
217
- continue;
218
- handlers[opName] = async (args) => {
219
- const scene = String((args && (args.scene || args.name)) || '').trim();
220
- const locked = await lockedSceneNames();
221
- if (scene && locked.includes(scene))
222
- return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
223
- return original(args);
224
- };
225
- }
226
285
  // 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
227
286
  // 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
228
287
  async function annotateLocked(res) {
@@ -0,0 +1,184 @@
1
+ export function installSceneLinkage(deps) {
2
+ /**
3
+ * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
4
+ * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
5
+ * 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
6
+ */
7
+ async function rebindSubagentInArchives(from, to) {
8
+ try {
9
+ const slice = await deps.memoriesService.readArchiveSlice();
10
+ const touched = {};
11
+ for (const [scene, archive] of Object.entries(slice.archives || {})) {
12
+ const list = archive.subagents;
13
+ if (!Array.isArray(list) || !list.includes(from))
14
+ continue;
15
+ touched[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
16
+ }
17
+ const names = Object.keys(touched);
18
+ // 逐场景增量(审查 P2-3):此前整表回写,会把窗口期里别人保存的**别的场景**档案抹掉。
19
+ for (const scene of names)
20
+ await deps.memoriesService.saveArchive(scene, touched[scene]);
21
+ return names.length;
22
+ }
23
+ catch {
24
+ return 0;
25
+ }
26
+ }
27
+ /**
28
+ * 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
29
+ * 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
30
+ * 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
31
+ */
32
+ async function renameSubagentInSnapshot(from, to) {
33
+ try {
34
+ const slice = await deps.memoriesService.readArchiveSlice();
35
+ const mode = slice && slice.mode;
36
+ const snapshot = mode && mode.snapshot;
37
+ if (!snapshot)
38
+ return;
39
+ let changed = false;
40
+ const next = { ...snapshot };
41
+ if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
42
+ const all = {};
43
+ for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
44
+ all[k === from ? to : k] = v;
45
+ if (k === from)
46
+ changed = true;
47
+ }
48
+ next.subagentsAll = all;
49
+ }
50
+ for (const field of ['subagents', 'subagentsOn']) {
51
+ const list = snapshot[field];
52
+ if (Array.isArray(list) && list.includes(from)) {
53
+ next[field] = list.map((n) => (n === from ? to : n));
54
+ changed = true;
55
+ }
56
+ }
57
+ if (changed)
58
+ await deps.memoriesService.patchIndex({ mode: { ...mode, snapshot: next } });
59
+ }
60
+ catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
61
+ }
62
+ const baseSubagentUpdate = deps.subagentService.ops['subagent-update'];
63
+ if (typeof baseSubagentUpdate === 'function') {
64
+ deps.subagentService.ops['subagent-update'] = async (args) => {
65
+ const res = await baseSubagentUpdate(args);
66
+ if (res && res.ok !== false && res.renamedFrom) {
67
+ const from = String(res.renamedFrom);
68
+ const to = String(res.name);
69
+ void rebindSubagentInArchives(from, to);
70
+ void renameSubagentInSnapshot(from, to);
71
+ }
72
+ return res;
73
+ };
74
+ }
75
+ /**
76
+ * 快捷提示词改名后同步场景档案(与人设改名那两条同一件事):档案里存的还是旧 id 的话,
77
+ * 下一次进场景它会被当成 stale 丢掉 —— 用户看到的就成了"改了个名字,场景里的勾选悄悄没了",
78
+ * 而且那条快捷词还会在进场景时被停用(它不在勾选集里了)。
79
+ */
80
+ async function rebindQuickPromptInArchives(from, to) {
81
+ try {
82
+ const slice = await deps.memoriesService.readArchiveSlice();
83
+ const touched = [];
84
+ for (const [scene, archive] of Object.entries(slice.archives || {})) {
85
+ const list = archive.quickPrompts;
86
+ if (!Array.isArray(list) || !list.includes(from))
87
+ continue;
88
+ touched.push([scene, { ...archive, quickPrompts: list.map((n) => (n === from ? to : n)) }]);
89
+ }
90
+ // 逐场景增量写(与审查 P2-3 同一条口径):整表回写会抹掉窗口期里别人存的别的场景。
91
+ for (const [scene, next] of touched)
92
+ await deps.memoriesService.saveArchive(scene, next);
93
+ }
94
+ catch { /* 同步失败不阻断改名本身;残留由档案保存时的 stale 清理兜住 */ }
95
+ }
96
+ const baseQuickPromptUpdate = deps.quickPromptOps['quickprompt-update'];
97
+ if (typeof baseQuickPromptUpdate === 'function') {
98
+ deps.quickPromptOps['quickprompt-update'] = async (args) => {
99
+ const res = await baseQuickPromptUpdate(args);
100
+ if (res && res.ok !== false && res.renamedFrom)
101
+ void rebindQuickPromptInArchives(String(res.renamedFrom), String(res.id));
102
+ return res;
103
+ };
104
+ }
105
+ // 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
106
+ // 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
107
+ const baseSceneModeSet = deps.archiveService.ops['scene-mode-set'];
108
+ if (typeof baseSceneModeSet === 'function') {
109
+ deps.archiveService.ops['scene-mode-set'] = async (args) => {
110
+ // 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
111
+ // 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
112
+ // 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
113
+ try {
114
+ const slice = await deps.memoriesService.readArchiveSlice();
115
+ const current = slice.mode && slice.mode.scene;
116
+ if (current && args && 'scene' in (args || {})) {
117
+ const target = args.scene == null ? null : String(args.scene).trim() || null;
118
+ if (target !== current) {
119
+ const locked = await deps.lockedSceneNames();
120
+ if (locked.includes(current)) {
121
+ return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
122
+ }
123
+ }
124
+ }
125
+ }
126
+ catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
127
+ const res = await baseSceneModeSet(args);
128
+ if (res && res.ok !== false) {
129
+ // 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
130
+ // 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
131
+ void deps.subagentCatalog.refresh().catch(() => { });
132
+ void deps.mcp.stateCatalog.refresh().catch(() => { });
133
+ }
134
+ return res;
135
+ };
136
+ }
137
+ /**
138
+ * 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
139
+ * (与进入场景时同一口径,见 archive-engine)——
140
+ * 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
141
+ * 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
142
+ * (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
143
+ */
144
+ const baseSceneArchiveSave = deps.archiveService.ops['scene-archive-save'];
145
+ if (typeof baseSceneArchiveSave === 'function') {
146
+ deps.archiveService.ops['scene-archive-save'] = async (args) => {
147
+ const res = await baseSceneArchiveSave(args);
148
+ try {
149
+ const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
150
+ const slice = await deps.memoriesService.readArchiveSlice();
151
+ const mode = slice.mode;
152
+ if (mode && mode.scene === res.scene) {
153
+ const every = (await deps.subagentService.list()).map((p) => p.name);
154
+ const unbound = every.filter((n) => bound.indexOf(n) < 0);
155
+ const toOn = await deps.subagentService.enabledStore.disabledAmong(bound);
156
+ const toOff = await deps.subagentService.enabledStore.enabledAmong(unbound);
157
+ if (toOn.length)
158
+ await deps.subagentService.enabledStore.setEnabled(toOn, true);
159
+ if (toOff.length)
160
+ await deps.subagentService.enabledStore.setEnabled(toOff, false);
161
+ if (toOn.length || toOff.length)
162
+ void deps.subagentCatalog.refresh();
163
+ const snapshot = mode.snapshot;
164
+ // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
165
+ // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
166
+ // archive-engine 的 restoreSnapshot 兼容分支)。
167
+ if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
168
+ const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
169
+ const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
170
+ for (const n of toOn)
171
+ if (backOn.indexOf(n) < 0)
172
+ backOn.push(n);
173
+ for (const n of toOff)
174
+ if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
175
+ offList.push(n);
176
+ await deps.memoriesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
177
+ }
178
+ }
179
+ }
180
+ catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
181
+ return res;
182
+ };
183
+ }
184
+ }
@@ -24,8 +24,11 @@
24
24
  // `driver()` —— 谁在驱动基线(提示词页「应用」据此判断:应用别的份 = 换掉该场景的绑定);
25
25
  // `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
26
26
  // `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
27
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
27
+ import { mkdir, readFile, stat } from 'node:fs/promises';
28
28
  import { dirname } from 'node:path';
29
+ // 基线快照走原子写(temp + rename):它是「退出场景时恢复 AGENTS.md」的**唯一依据**,
30
+ // 裸写中断留下半截 JSON 会让 `readBaseline` 解析失败(审查 P2-16)。
31
+ import { writeFileAtomically } from './memories/index-io.js';
29
32
  export function createScenePromptSync(deps) {
30
33
  const warn = (message) => { try {
31
34
  deps.logger?.warn?.(message);
@@ -48,13 +51,35 @@ export function createScenePromptSync(deps) {
48
51
  return null;
49
52
  }
50
53
  }
54
+ /** 快照文件是否**存在**(不看内容)。用来把「没有快照」与「快照读不出来」分开。 */
55
+ async function baselineExists() {
56
+ try {
57
+ return (await stat(deps.baselineFile)).isFile();
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * 写基线快照。**失败必须让调用方看得见**(2026-09-30 审查 F14)。
65
+ *
66
+ * 原来这里 catch 后只 `warn` 并返回 void,于是「快照没写成」与「写成了」在调用方眼里
67
+ * 完全一样 —— 结果就是:AGENTS.md 被切到场景态、而退出场景时恢复不回来,用户看到的是
68
+ * 「没场景驱动所以什么都没做」。这正是「看起来成功」那类失败。
69
+ * @returns 失败原因(原样回给调用方拼进提示);成功返回 null。
70
+ */
51
71
  async function writeBaseline(next) {
52
72
  try {
53
73
  await mkdir(dirname(deps.baselineFile), { recursive: true });
54
- await writeFile(deps.baselineFile, JSON.stringify(next), 'utf8');
74
+ // 原子写(2026-09-30 审查 P2-16):裸写在中断/断电时会留下半截 JSON → `readBaseline`
75
+ // 解析失败返回 null → 退出场景**不恢复** AGENTS.md,用户的提示词基线悄悄留在场景态。
76
+ await writeFileAtomically(deps.baselineFile, JSON.stringify(next));
77
+ return null;
55
78
  }
56
79
  catch (e) {
57
- warn(`scene-prompt: could not persist baseline: ${String(e)}`);
80
+ const reason = String(e);
81
+ warn(`scene-prompt: could not persist baseline: ${reason}`);
82
+ return reason;
58
83
  }
59
84
  }
60
85
  async function state() {
@@ -157,25 +182,39 @@ export function createScenePromptSync(deps) {
157
182
  return { applied: current.presetId, unchanged: true };
158
183
  // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
159
184
  // 基线始终是"进场景之前"那一份)。
185
+ let baselineError;
160
186
  const saved = await readBaseline();
161
187
  if (!saved || saved.content === null) {
162
188
  const cur = await deps.prompts.getCurrent();
163
- await writeBaseline({
189
+ const failed = await writeBaseline({
164
190
  v: 1,
165
191
  at: Date.now(),
166
192
  presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
167
193
  content: cur && cur.ok && cur.exists ? String(cur.content) : null,
168
194
  });
195
+ // 基线写不进去 = 退出场景时恢复不回来。**照旧进场景**(那是用户的要求),
196
+ // 但必须当场说出来,别让用户以为还能退回去(审查 F14)。
197
+ if (failed !== null) {
198
+ baselineError = `进场景前的提示词基线没能写入(${failed}):AGENTS.md 已切到场景「${current.scene}」绑定的预设,但退出场景时恢复不回原来的内容(${deps.baselineFile})。`;
199
+ }
169
200
  }
170
201
  const res = await deps.prompts.apply(current.presetId);
171
202
  if (res && res.ok === false)
172
- return { error: String(res.error || '写入 AGENTS.md 失败') };
173
- return { applied: current.presetId };
203
+ return { error: String(res.error || '写入 AGENTS.md 失败'), ...(baselineError ? { baselineError } : {}) };
204
+ return { applied: current.presetId, ...(baselineError ? { baselineError } : {}) };
174
205
  }
175
206
  // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
176
207
  const saved = await readBaseline();
177
- if (!saved || saved.content === null)
208
+ if (!saved || saved.content === null) {
209
+ // 「没有快照」是常态(没进过场景);「快照文件在、但读不出来」是**故障** ——
210
+ // 那意味着恢复目标丢了,AGENTS.md 的提示词基线会悄悄留在场景态。这里曾经一律
211
+ // 静默 `unchanged`,用户看到的是"没场景驱动所以什么都没做"(审查 P2-16)。
212
+ if (await baselineExists()) {
213
+ warn(`scene-prompt: baseline snapshot exists but is unreadable: ${deps.baselineFile}`);
214
+ return { error: `基线快照读不出来(文件可能损坏),未能恢复 AGENTS.md:${deps.baselineFile}` };
215
+ }
178
216
  return { unchanged: true };
217
+ }
179
218
  const res = await deps.prompts.restore(saved.content);
180
219
  if (res && res.ok === false)
181
220
  return { error: String(res.error || '恢复 AGENTS.md 失败') };
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
15
15
  // 放进本文件会让两个域互相引用。
16
+ import { MCP_TOOL_PREFIX } from '../host-names.js';
17
+ import { serverNameCandidates } from '../host-names.js';
16
18
  import { presetRosterOf } from '../compat/preset-reach.js';
17
19
  import { injectionFactsOf, readCompositionFacts, readCompositionText } from '../compat/preset-reach.js';
18
20
  /**
@@ -39,9 +41,25 @@ export function createCandidates(deps) {
39
41
  schemas = await tools.schemas();
40
42
  }
41
43
  catch { /* 无 live 工具 → 仅启停表 */ }
44
+ // 切分的候选 serverName 集合必须与 `serverKnownTools`(index.ts)同源 —— 同一批来源、
45
+ // 同一个 builder,否则「服务器名含 `__`」时两处会把同一个全名切成不同的 (server, tool)
46
+ // (2026-09-30 审查 N1)。三个来源里补丁那份是唯一与切分结果无关的(另两份是插件自己
47
+ // 写出来的:停用表键来自界面勾选、缓存键来自上一次切分),缺它会在首次遇到含 `__` 的名字时
48
+ // 切错并锁死。任一份读不到就退化为其余两份(少候选 = 少命中,不会多命中)。
49
+ let known = {};
50
+ try {
51
+ known = await mcp.readKnownMcpTools();
52
+ }
53
+ catch { /* 缓存不可读 → 仅停用表 */ }
54
+ let configured = [];
55
+ try {
56
+ configured = await mcp.configuredServerNames();
57
+ }
58
+ catch { /* 补丁读不到 → 仅另两份 */ }
59
+ const serverNames = serverNameCandidates(configured, disabled, known);
42
60
  const liveByServer = {};
43
61
  for (const s of schemas) {
44
- const p = toolKeyParts(String((s && s.name) || ''));
62
+ const p = toolKeyParts(String((s && s.name) || ''), serverNames);
45
63
  if (!p)
46
64
  continue;
47
65
  const list = liveByServer[p.server] || (liveByServer[p.server] = []);
@@ -214,7 +232,7 @@ export function createCandidates(deps) {
214
232
  continue;
215
233
  // MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
216
234
  // 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
217
- const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
235
+ const names = (await presetToolNames(id)).filter((name) => !name.startsWith(MCP_TOOL_PREFIX));
218
236
  presets.push({
219
237
  id,
220
238
  name: String((p && (p.name || p.id)) || id),
@@ -236,7 +254,7 @@ export function createCandidates(deps) {
236
254
  }
237
255
  // 当前会话的工具即便没有任何预设可枚举,也要出现在候选里(否则选择器是空的)。
238
256
  for (const name of currentNames) {
239
- if (name.startsWith('mcp__'))
257
+ if (name.startsWith(MCP_TOOL_PREFIX))
240
258
  continue;
241
259
  const rec = byName.get(name) || { name, presets: new Set(), current: false };
242
260
  byName.set(name, rec);