dsh-plugin-tool-management 0.14.0 → 0.15.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 (43) hide show
  1. package/CHANGELOG.md +560 -519
  2. package/docs/images/1-EN.png +0 -0
  3. package/docs/images/1.png +0 -0
  4. package/docs/images/2-EN.png +0 -0
  5. package/docs/images/2.png +0 -0
  6. package/docs/images/3-EN.png +0 -0
  7. package/docs/images/3.png +0 -0
  8. package/docs/images/4-EN.png +0 -0
  9. package/docs/images/4.png +0 -0
  10. package/docs/images/5-EN.png +0 -0
  11. package/docs/images/5.png +0 -0
  12. package/docs/images/6-EN.png +0 -0
  13. package/docs/images/6.png +0 -0
  14. package/docs/images/7-EN.png +0 -0
  15. package/docs/images/7.png +0 -0
  16. package/docs/images/8-EN.png +0 -0
  17. package/docs/images/8.png +0 -0
  18. package/docs/update.md +391 -365
  19. package/lib/audit-log.js +157 -0
  20. package/lib/client.js +3800 -2504
  21. package/lib/compat/patch-dialect.js +3 -1
  22. package/lib/compat/preset-reach.js +37 -6
  23. package/lib/compat/probe.js +149 -7
  24. package/lib/context-inject.js +5 -3
  25. package/lib/index.js +236 -30
  26. package/lib/mcp/loader-token.js +53 -28
  27. package/lib/mcp/manager.js +146 -7
  28. package/lib/memories/archive-engine.js +70 -2
  29. package/lib/memories/archive.js +8 -1
  30. package/lib/memories/index-io.js +6 -0
  31. package/lib/op-registry.js +18 -4
  32. package/lib/ops/compat.js +109 -35
  33. package/lib/ops/scene-records.js +34 -0
  34. package/lib/ops/scene-sync.js +12 -1
  35. package/lib/ops/sessions.js +23 -8
  36. package/lib/ops/snapshot.js +596 -0
  37. package/lib/scenes/candidates.js +10 -5
  38. package/lib/sessions/bridge.js +5 -0
  39. package/lib/sessions/workspace.js +17 -5
  40. package/lib/skills/core.js +12 -3
  41. package/lib/tools/deps.js +1 -1
  42. package/lib/tools/table.js +5 -1
  43. package/package.json +112 -108
package/lib/index.js CHANGED
@@ -22,6 +22,8 @@ import { isInsideRootResolved } from './paths.js';
22
22
  import { DEFAULT_PROFILE_NAME, PROFILE_CANDIDATES } from './host-names.js';
23
23
  import { createMemoriesService } from './memories/service.js';
24
24
  import { createArchiveEngine } from './memories/archive-engine.js';
25
+ import { createSnapshotOps } from './ops/snapshot.js';
26
+ import { AUDIT_TARGET_CONFIG, clearAudit, readAudit, recordAudit, recordOpAudit } from './audit-log.js';
25
27
  import { readIndexSync } from './memories/index-io.js';
26
28
  import { runStateDoctor } from './ops/state-doctor.js';
27
29
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
@@ -29,7 +31,7 @@ import { createSubagentCatalog } from './subagents/catalog.js';
29
31
  import { createSkillCatalog } from './skills/catalog.js';
30
32
  import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
31
33
  import { isApprovalNever } from './approval-policy.js';
32
- import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } from './tools/table.js';
34
+ import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, FACTORY_TOOL_TABLE_PRESET, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } from './tools/table.js';
33
35
  import { normalizeSceneSettings } from './scene-settings.js';
34
36
  import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
35
37
  import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
@@ -42,7 +44,7 @@ import { createCandidates } from './scenes/candidates.js';
42
44
  // 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
43
45
  // 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
44
46
  export { normalizeKnownTools, countEnabledTools } from './mcp/manager.js';
45
- import { applyLoaderToken, applyLoaderTokenDisabled, readLoaderToken } from './mcp/loader-token.js';
47
+ import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken } from './mcp/loader-token.js';
46
48
  import { hubBackupDir, hubPath, migrateHubLayoutSync, relocateEntries } from './hub.js';
47
49
  // 分域的 HTTP ops(2026-09-19 从本文件 handlers 表抽出,依赖显式传参)。
48
50
  import { buildCompatOps } from './ops/compat.js';
@@ -163,6 +165,8 @@ export default {
163
165
  kind: 'read',
164
166
  fallback: 'inform-only',
165
167
  detail: '技能 provider 装配失败(' + message(e) + '):技能启用/停用与 agent 技能目录这一轮不生效。',
168
+ detailKey: 'skills-provider',
169
+ params: { reason: message(e) },
166
170
  });
167
171
  }
168
172
  // ---------- 提示词预设库(hub/prompts/)+ 切换 ----------
@@ -296,7 +300,41 @@ export default {
296
300
  // 用生效集去校验会把合法勾选判成 stale 丢掉。
297
301
  return (await skillScan()).all;
298
302
  }
303
+ // 整体替换关停名单的那一步抽成具名函数:引擎的 apply 依赖与「修改场景」表单的
304
+ // 即时生效(scene-sync 包装)共用同一条写路径,不能各抄一份。
305
+ const applyToolTableHiddenNow = async (hidden) => {
306
+ // 与 `tool-table` op 写分支同一条路径、同一顺序:落盘 → 重排可见性 → 两个目录重算。
307
+ // 绕开这套顺序就会留下「名单已经改了、工具下一轮还发给模型」这种最难查的状态。
308
+ const current = await readToolTableSettings();
309
+ const next = {
310
+ hidden: normalizeToolTableSettings({ hidden }).hidden.filter((name) => toolTableSizes.has(name)),
311
+ presets: current.presets,
312
+ };
313
+ const saved = await writeToolTableSettings(next);
314
+ if (!saved.ok)
315
+ throw new Error(saved.error);
316
+ mcp.scheduleToolRestrictions();
317
+ await Promise.all([
318
+ skillCatalog.refresh().catch(() => { }),
319
+ subagentCatalog.refresh().catch(() => { }),
320
+ ]);
321
+ };
299
322
  const archiveService = createArchiveEngine({
323
+ // 引擎每应用/还原一次整套档案记一条流水(source='engine',见 ./audit-log.ts)。
324
+ // 记在边界这一层而不是往五个 apply* 里散着写:一条流水说的是「引擎动了整个环境」,
325
+ // 与同一次进入里面板/模型那条记录天然按秒分组显示。
326
+ audit: (op, target) => recordAudit({ ts: Date.now(), op, target: target || AUDIT_TARGET_CONFIG, source: 'engine' }),
327
+ // ── 模型工具表(0.15.0 C1):场景可以绑一份方案,进场景整体换成那一份 ──
328
+ currentToolTableHidden: async () => [...(await readToolTableSettings()).hidden],
329
+ toolTablePresetHidden: async (name) => {
330
+ const s = await readToolTableSettings();
331
+ // 用户存的方案**优先**:万一有人把方案起名叫 'factory-default',他要的是自己那份。
332
+ const hit = s.presets.filter((p) => p.name === name)[0];
333
+ if (hit)
334
+ return [...hit.hidden];
335
+ return name === FACTORY_TOOL_TABLE_PRESET ? [...DEFAULT_HIDDEN_TOOLS] : null;
336
+ },
337
+ applyToolTableHidden: applyToolTableHiddenNow,
300
338
  loadSlice: async () => ({ ...(await memoriesService.readArchiveSlice()) }),
301
339
  saveSlice: (slice) => memoriesService.patchIndex(slice),
302
340
  configuredServers: async () => {
@@ -803,7 +841,7 @@ export default {
803
841
  return { ok: true, settings: next };
804
842
  });
805
843
  }
806
- // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
844
+ // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的各域文本。
807
845
  // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
808
846
  let contextInjectorLive = null;
809
847
  // 采纳遥测的入口(同样是 apply 作用域的中转):工具注册在 effect 之外,
@@ -876,7 +914,9 @@ export default {
876
914
  label: '上下文注入通道',
877
915
  kind: 'read',
878
916
  fallback: 'inform-only',
879
- detail: '注入通道装配失败(' + message(e) + '):五个注入域的内容这一轮不会被送进模型。',
917
+ detail: '注入通道装配失败(' + message(e) + '):全部注入域的内容这一轮不会被送进模型。',
918
+ detailKey: 'context-injection',
919
+ params: { reason: message(e) },
880
920
  });
881
921
  }
882
922
  void readInjectSettings().catch(() => { });
@@ -903,6 +943,7 @@ export default {
903
943
  kind: 'read',
904
944
  fallback: 'inform-only',
905
945
  detail: 'cordis 导出的 symbols.original 与 Symbol.for("cordis.original") 不是同一个符号:能力探测可能把代理当原始对象,身份判定会失真。',
946
+ detailKey: 'cordis-original-symbol',
906
947
  });
907
948
  }
908
949
  }
@@ -1190,23 +1231,87 @@ export default {
1190
1231
  if (cached)
1191
1232
  return cached;
1192
1233
  let home = null;
1234
+ let derivedProfileDir = null;
1235
+ let derivedProfileName = null;
1236
+ let docObserved;
1237
+ let docError;
1193
1238
  try {
1194
1239
  // 注意:这里的 home 来自宿主文档路径的切片,与 `skills/core.js` 的
1195
1240
  // `resolveDshHome()`(`$DSH_HOME` ‖ `~/.dsh`)是**两条独立来源**,本插件同时用着两者。
1196
1241
  // 不合并是有意的:合并会改变所有 profile 补丁的落点,属于行为变更,留给结构性重构那一档。
1197
1242
  const doc = await settings.prepareDocument();
1198
1243
  if (typeof doc === 'string' && doc) {
1244
+ docObserved = doc;
1199
1245
  const i = Math.max(doc.lastIndexOf('\\'), doc.lastIndexOf('/'));
1200
- home = i > 0 ? doc.slice(0, i) : doc;
1246
+ const docDir = i > 0 ? doc.slice(0, i) : doc;
1247
+ // 0.1.7 起 `prepareDocument()` 返回的是 **profile 补丁**(`configEditor.documentPath`,
1248
+ // 形如 `<home>\profiles\web\cordis.patch.yml`);0.1.5 返回的才是主目录下的设置文档。
1249
+ // 直接切片会把 profile 目录当成主目录 —— globalPatch 随之指到 profile 补丁,
1250
+ // 全局补丁里那些 MCP 行就全部读不到了(0.15.0 实测)。所以先认这个形状:
1251
+ // 目录以 `profiles\<name>` 结尾 ⇒ 它自己就是 profileDir,主目录上溯两级。
1252
+ const profileShaped = /^(.+)[\\/]profiles[\\/]([^\\/]+)$/.exec(docDir);
1253
+ if (profileShaped) {
1254
+ home = profileShaped[1];
1255
+ derivedProfileDir = docDir;
1256
+ derivedProfileName = profileShaped[2];
1257
+ }
1258
+ else {
1259
+ home = docDir;
1260
+ }
1201
1261
  }
1202
1262
  }
1203
- catch (e) { /* ignore */ }
1204
- if (!home)
1263
+ catch (e) {
1264
+ docError = String(e?.message ?? e);
1265
+ }
1266
+ if (!home) {
1267
+ // 推导失败从「静默抛错」升级为「具名上报 + 抛错」:这条路径正是 0.1.7 升级事故的
1268
+ // 源头(prepareDocument 返回语义一变这里就指错目录,MCP 页与补丁读写全部失明),
1269
+ // 下次契约再变,兼容页必须第一眼看到推导挂了,而不是等某个页面空白再倒查。
1270
+ noteRuntime({
1271
+ id: 'paths.home-derivation',
1272
+ label: '主目录推导',
1273
+ kind: 'read',
1274
+ fallback: 'refuse-operation',
1275
+ detail: docError !== undefined
1276
+ ? `settings.prepareDocument() 抛错:${docError}。主目录推导失败,MCP 页与补丁读写不可用。`
1277
+ : 'settings.prepareDocument() 未返回可用路径(宿主路径契约可能又变了)。主目录推导失败,MCP 页与补丁读写不可用。',
1278
+ });
1205
1279
  throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
1280
+ }
1206
1281
  const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
1207
- let profileDir = null;
1208
- let profileName = DEFAULT_PROFILE_NAME;
1209
- for (const name of PROFILE_CANDIDATES) {
1282
+ // prepareDocument 给出的就是**现存**的 profile 补丁路径(官方注释如此),所以
1283
+ // 推导出的 profileDir 直接可信;只有它没给出 profile 形状时才走下面的候选探测。
1284
+ const profileShapedPatchExists = derivedProfileDir !== null
1285
+ && (await exists(derivedProfileDir + sep + 'cordis.patch.yml'));
1286
+ // —— 推导自检(0.15.0):健康时不留 note(运行时上报一律渲染成兼容页的问题行,
1287
+ // 常驻健康行会违反「琥珀只留给故障」的口径),只在推导结果反常时记,恢复时收掉。
1288
+ // 两条不变量照着 0.1.7 事故的形状挑:那次推出的「主目录」其实存在(是 profile 目录),
1289
+ // 存在性检查抓不住,**profiles 子目录**检查才抓得住 —— 主目录下永远有 profiles\
1290
+ //(本插件自己的 profile 就住在里面)。官方再改一次语义,两道检查至少一道当场报警。
1291
+ const derivationNote = (detail) => {
1292
+ noteRuntime({
1293
+ id: 'paths.home-derivation',
1294
+ label: '主目录推导',
1295
+ kind: 'read',
1296
+ fallback: 'refuse-operation',
1297
+ detail,
1298
+ });
1299
+ };
1300
+ if (!(await exists(home))) {
1301
+ derivationNote(`推导出的主目录不存在:${home}(设置文档路径:${docObserved ?? '未知'})。推导结果不可信,MCP 页与补丁读写会落空。`);
1302
+ }
1303
+ else if (!(await exists(home + sep + 'profiles'))) {
1304
+ derivationNote(`推导出的主目录下没有 profiles 目录:${home}(设置文档路径:${docObserved ?? '未知'})。主目录很可能指错了 —— 直接切片一个 profile 补丁路径就是这个形状;MCP 页与补丁读写会读错文件。`);
1305
+ }
1306
+ else if (derivedProfileDir !== null && !profileShapedPatchExists) {
1307
+ derivationNote(`设置文档路径呈 profile 目录形状,但其中没有 cordis.patch.yml:${docObserved}。官方承诺返回「现存」的 profile 补丁路径,形状对不上说明契约又变了;已回退候选探测。`);
1308
+ }
1309
+ else {
1310
+ clearRuntimeNote('paths.home-derivation');
1311
+ }
1312
+ let profileDir = profileShapedPatchExists ? derivedProfileDir : null;
1313
+ let profileName = derivedProfileName ?? DEFAULT_PROFILE_NAME;
1314
+ for (const name of profileDir === null ? PROFILE_CANDIDATES : []) {
1210
1315
  if (await exists(home + sep + 'profiles' + sep + name + sep + 'cordis.patch.yml')) {
1211
1316
  profileDir = home + sep + 'profiles' + sep + name;
1212
1317
  profileName = name;
@@ -1237,6 +1342,10 @@ export default {
1237
1342
  profileName,
1238
1343
  projectPatch: profileDir + sep + 'cordis.patch.yml',
1239
1344
  globalPatch: home + sep + 'cordis.patch.yml',
1345
+ // 0.1.7 bundle 层:插件包内自己的 cordis.patch.yml(`dsh.bundle.patch`)定义 loader
1346
+ // 条目。它只是「覆盖目标存在吗」的凭证与读取参照,**绝不是令牌的写点** —— 那份文件
1347
+ // 是仓库镜像(sync 每次构建覆盖),把令牌写进去等于把明文密钥提交进仓库。
1348
+ bundlePatch: profileDir + sep + 'node_modules' + sep + 'dsh-plugin-tool-management' + sep + 'cordis.patch.yml',
1240
1349
  };
1241
1350
  return cached;
1242
1351
  }
@@ -1475,7 +1584,17 @@ export default {
1475
1584
  infoOf.set(abs, { token: read.token, disabled: read.disabled });
1476
1585
  }
1477
1586
  }
1478
- return { files, infoOf };
1587
+ // 0.1.7 bundle 挂载:loader 条目由插件包内的 cordis.patch.yml(bundle 层)提供,两份
1588
+ // 补丁里都没有可就地改写的行。bundleBase 只回答「覆盖目标存在吗」—— 写点是 profile
1589
+ // 补丁里的覆盖条目(见 tokenConfigure),bundle 文件本身永不落密钥。
1590
+ let bundleBase = false;
1591
+ try {
1592
+ bundleBase = readLoaderToken(await readPatch(p.bundlePatch)).found;
1593
+ }
1594
+ catch {
1595
+ bundleBase = false;
1596
+ }
1597
+ return { files, infoOf, bundleBase };
1479
1598
  }
1480
1599
  /**
1481
1600
  * 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
@@ -1565,13 +1684,39 @@ export default {
1565
1684
  if (value === CONFIG_TOKEN)
1566
1685
  return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
1567
1686
  }
1568
- const { files } = await loaderTokenFiles();
1569
- if (!files.length) {
1570
- return { ok: false, error: '没找到本插件的 loader 行,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动设置 config.token(或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)。' };
1687
+ const p = await ensurePaths();
1688
+ const { files, bundleBase } = await loaderTokenFiles();
1689
+ if (!files.length && !(mode === 'set' && bundleBase)) {
1690
+ return { ok: false, error: '没找到本插件的 loader 行,也无法确认 bundle 层的挂载条目,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动加一条覆盖条目(- id: dsh-plugin-tool-management → config: → token: "…"),或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN。' };
1571
1691
  }
1572
1692
  if (files.length > 1) {
1573
1693
  return { ok: false, error: '本插件的 loader 行同时出现在两份补丁文件里(会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
1574
1694
  }
1695
+ if (!files.length) {
1696
+ // 0.1.7 bundle 挂载:两份补丁里都没有本插件的 loader 行(条目由 bundle 层提供,那份
1697
+ // 文件是仓库镜像、不能落密钥)—— 令牌写进 **profile 补丁的覆盖条目**:宿主在所有
1698
+ // bundle 层之后应用它,整体替换那条 loader 的 config(bundle 条目没有 config,替换
1699
+ // 零丢失);同 id 的 insert 条目才会重复挂载导致启动失败,覆盖条目是官方支持的改法。
1700
+ // `off` / `clear` / `on` 走不到这里:有 site 时就地改写,没 site 且有令牌 = 令牌来自
1701
+ // 环境变量,那两条路在前面已按各自口径返回。
1702
+ return withWriteLock(async () => {
1703
+ let content = '';
1704
+ try {
1705
+ content = await readPatch(p.projectPatch);
1706
+ }
1707
+ catch (e) {
1708
+ return { ok: false, error: '读取补丁失败: ' + message(e) };
1709
+ }
1710
+ const next = content === '' ? buildLoaderOverrideEntry(value) + '\n' : (content.endsWith('\n') ? content : content + '\n') + '\n' + buildLoaderOverrideEntry(value);
1711
+ try {
1712
+ await writePatch(p.projectPatch, next);
1713
+ }
1714
+ catch (e) {
1715
+ return { ok: false, error: '写入补丁失败: ' + message(e) };
1716
+ }
1717
+ return { ok: true, changed: true, mode, restartRequired: true, path: p.projectPatch };
1718
+ });
1719
+ }
1575
1720
  const abs = files[0];
1576
1721
  return withWriteLock(async () => {
1577
1722
  let content = '';
@@ -1774,6 +1919,10 @@ export default {
1774
1919
  });
1775
1920
  const handlers = {
1776
1921
  'plugin-version': pluginVersion,
1922
+ // 「最近改动」流水的读侧(0.15.0 B2):只读 hub 里的 `audit.jsonl`,倒序、可按域过滤。
1923
+ 'audit-list': async (args) => ({ ok: true, entries: await readAudit(args || {}) }),
1924
+ // 清空流水。清完这一条 op 自己会被下面的包装层记进新流水("谁清过"也留痕)。
1925
+ 'audit-clear': async () => await clearAudit(),
1777
1926
  'skill-open': skillOpen,
1778
1927
  // MCP 域 16 个 op(实现在 ./mcp/manager.ts)
1779
1928
  ...mcp.ops,
@@ -1812,10 +1961,11 @@ export default {
1812
1961
  cleanPatchBackups,
1813
1962
  getContextInjectorLive: () => contextInjectorLive,
1814
1963
  // 功能总览(B6)的三层合成:令牌实况与兼容页「访问令牌」同源;场景锁定与写门禁同源;
1815
- // 停用工具数读 mcp 的 TTL 缓存(无 I/O)。
1964
+ // 停用工具的两个口径读 mcp 的 TTL 缓存(无 I/O)。
1816
1965
  readTokenState: async () => ({ active: TOKEN !== '', accepted: TOKEN !== '' && acceptedThisBoot() }),
1817
1966
  lockedSceneNames: () => lockedSceneNames(),
1818
- disabledToolCount: () => mcp.disabledToolCount(),
1967
+ disabledToolSummary: () => mcp.disabledToolSummary(),
1968
+ injectServiceCount: INJECT_SERVICES.length,
1819
1969
  message,
1820
1970
  compatLog,
1821
1971
  }),
@@ -1849,6 +1999,20 @@ export default {
1849
1999
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
1850
2000
  ...buildSceneSyncOps({
1851
2001
  withAgentsMdSync,
2002
+ onToolTablePresetChanged: async (scene, value) => {
2003
+ // 「修改场景」里改绑工具表方案且改的是**当前启用场景** → 立即把关停名单整体换成
2004
+ // 那一份(与「改档案 = 立刻生效」同口径)。解绑('')按下拉里的承诺 =「保持现状」,
2005
+ // 运行时不动;悬空绑定(方案已删)也不在这里硬失败,交给引擎进出场景时的 stale 处理。
2006
+ const slice = await memoriesService.readArchiveSlice();
2007
+ if (!slice.mode || slice.mode.scene !== scene || !value)
2008
+ return;
2009
+ const s = await readToolTableSettings();
2010
+ const hit = s.presets.filter((p) => p.name === value)[0];
2011
+ const hidden = hit ? [...hit.hidden] : (value === FACTORY_TOOL_TABLE_PRESET ? [...DEFAULT_HIDDEN_TOOLS] : null);
2012
+ if (!hidden)
2013
+ return;
2014
+ await applyToolTableHiddenNow(hidden);
2015
+ },
1852
2016
  rulesOps: {
1853
2017
  'rules-set-active': (args) => memoriesService.ops['rules-set-active'](args),
1854
2018
  'rules-update-scene': (args) => memoriesService.ops['rules-update-scene'](args),
@@ -2252,6 +2416,22 @@ export default {
2252
2416
  // handlers 后处理(2026-09-19 抽到 ./request-gate.ts):场景锁定守卫、开关同步档案、
2253
2417
  // 读 op 的 anyLocked/activeScene 注解、子智能体失败清单 attach。
2254
2418
  // 顺序即语义(先装的在内层),整段搬运未改。
2419
+ //
2420
+ // 整机迁移快照(0.15.0 C2)在门禁装好**之前**并入 handlers:它自己不写任何域的数据,
2421
+ // 而是逐域调 handlers 表里那些 op —— 装好后取到的就是带门禁的那一份,场景锁定期间各域
2422
+ // 自己拒绝,迁移这边不必再判一套锁。
2423
+ const snapshotOps = createSnapshotOps({
2424
+ invokeOp: (name, args) => {
2425
+ const fn = handlers[name];
2426
+ return fn ? fn(args || {}) : Promise.resolve({ ok: false, error: '未知操作: ' + name });
2427
+ },
2428
+ hubPath,
2429
+ ensurePaths,
2430
+ pluginVersion: () => PKG_VERSION,
2431
+ promptsDir: () => promptsDir,
2432
+ message,
2433
+ });
2434
+ Object.assign(handlers, snapshotOps);
2255
2435
  installHandlerGuards({
2256
2436
  handlers,
2257
2437
  lockedSceneNames,
@@ -2329,8 +2509,28 @@ export default {
2329
2509
  */
2330
2510
  const hostDefineToolAny = hostDefineTool;
2331
2511
  const defineTool = ((options) => trackAdoption(hostDefineToolAny(options)));
2332
- // 五个域共用的那一份依赖(形状见 tools/deps.ts)。defineTool 带着遥测一起传下去 ——
2512
+ // 各域共用的那一份依赖(形状见 tools/deps.ts)。defineTool 带着遥测一起传下去 ——
2333
2513
  // 各域必须用它,直接 import 宿主的那个会丢掉采纳统计。
2514
+ /**
2515
+ * 「最近改动」流水的模型侧挂点(0.15.0 B2)。
2516
+ *
2517
+ * 为什么包在**交给工具的 op 表**上而不是散进每个 op:op 执行有多条入口路径(HTTP 路由、
2518
+ * 模型工具的 execute、场景引擎),而判断「谁干的」这件事只有调用点知道 —— 在 op 里自己猜
2519
+ * 调用方就是猜。这里给工具看到的表包一层,界面上 handlers 表那份原样不动,两条路各记各的。
2520
+ * 只多记一条流水:不改行为、不改返回值、也不吞异常(写类与成败的判据在 ./audit-log.ts)。
2521
+ */
2522
+ const auditFn = (opName, fn) => async (args) => {
2523
+ const result = await fn(args || {});
2524
+ recordOpAudit(opName, args || {}, 'model', result);
2525
+ return result;
2526
+ };
2527
+ const auditOps = (ops) => {
2528
+ const out = {};
2529
+ for (const [name, fn] of Object.entries(ops)) {
2530
+ out[name] = typeof fn === 'function' ? auditFn(name, fn) : fn;
2531
+ }
2532
+ return out;
2533
+ };
2334
2534
  const toolDeps = {
2335
2535
  defineTool,
2336
2536
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
@@ -2345,26 +2545,27 @@ export default {
2345
2545
  injectNoticeOptions,
2346
2546
  message,
2347
2547
  };
2548
+ const mcpToolOps = auditOps(mcp.ops);
2348
2549
  buildMcpTools({
2349
2550
  ...toolDeps,
2350
2551
  mcpmListView: mcp.mcpmListView,
2351
2552
  mcpmTools: mcp.ops['mcpm-tools'],
2352
- mcpmSetEnabled: mcp.ops['mcpm-set-enabled'],
2353
- mcpmRestart: mcp.ops['mcpm-restart'],
2354
- mcpmToolEnabled: mcp.ops['mcpm-tool-enabled'],
2355
- mcpmAdd: mcp.ops['mcpm-add'],
2356
- mcpmEdit: mcp.ops['mcpm-edit'],
2357
- mcpmNote: mcp.ops['mcpm-note'],
2553
+ mcpmSetEnabled: mcpToolOps['mcpm-set-enabled'],
2554
+ mcpmRestart: mcpToolOps['mcpm-restart'],
2555
+ mcpmToolEnabled: mcpToolOps['mcpm-tool-enabled'],
2556
+ mcpmAdd: mcpToolOps['mcpm-add'],
2557
+ mcpmEdit: mcpToolOps['mcpm-edit'],
2558
+ mcpmNote: mcpToolOps['mcpm-note'],
2358
2559
  // **故意不接 `mcpm-reveal`**:`mcp_manager_save` 的改分支用 `mcpmListView()` 的
2359
2560
  // 打码视图填回省略字段,由 `mcpm-edit` 的 `resolveMaskedKv` / `resolveMaskedUrl`
2360
2561
  // 还原真值 —— 那条路本来就是给"表单里出现打码值"设计的(界面编辑框预填的就是它)。
2361
2562
  // 让模型驱动的工具在进程内读明文凭据,是另一条没人设计过、也没有测试覆盖的路径。
2362
2563
  });
2363
- buildSkillTools({ ...toolDeps, skillsOps: skillsService.ops });
2564
+ buildSkillTools({ ...toolDeps, skillsOps: auditOps(skillsService.ops) });
2364
2565
  // 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
2365
2566
  // 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
2366
- buildPromptTools({ ...toolDeps, promptOps, applyPresetGuarded, promptsDir });
2367
- buildMemoryTools({ ...toolDeps, rulesOps: memoriesService.ops });
2567
+ buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded, promptsDir });
2568
+ buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2368
2569
  // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2369
2570
  // + 进入与退出(`_switch`)。
2370
2571
  // 传**包装后**的 `archiveService.ops`:`scene-archive-save` 与 `scene-mode-set` 都在本文件
@@ -2376,13 +2577,14 @@ export default {
2376
2577
  // 再包一次,是为了让工具与 HTTP API 共用同一个实现(不会各自漂移)。
2377
2578
  buildSceneTools({
2378
2579
  ...toolDeps,
2379
- rulesOps: memoriesService.ops,
2380
- archiveOps: archiveService.ops,
2381
- sceneActivate: (args) => handlers['rules-set-active'](args),
2580
+ rulesOps: auditOps(memoriesService.ops),
2581
+ archiveOps: auditOps(archiveService.ops),
2582
+ sceneActivate: auditFn('rules-set-active', (args) => handlers['rules-set-active'](args)),
2382
2583
  });
2383
2584
  buildSubagentTools({
2384
2585
  ...toolDeps,
2385
- subagentService,
2586
+ // 只换掉工具看到的 `ops` 那一层(原型链回真实服务,其余读法一字不动)。
2587
+ subagentService: Object.assign(Object.create(subagentService), { ops: auditOps(subagentService.ops) }),
2386
2588
  sceneLists: subagentSceneLists,
2387
2589
  toolFilterFor: subagentToolFilterFor,
2388
2590
  failures: subagentFailures,
@@ -2829,6 +3031,10 @@ export default {
2829
3031
  return;
2830
3032
  }
2831
3033
  const result = await fn(payload.args || {});
3034
+ // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
3035
+ // `readOnlyCall` 是上面那四个「读改写两态 op」的纯读分支 —— 它们没改任何状态,不记。
3036
+ if (!readOnlyCall)
3037
+ recordOpAudit(op, payload.args || {}, 'panel', result);
2832
3038
  res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
2833
3039
  }
2834
3040
  catch (e) {
@@ -9,8 +9,10 @@
9
9
  // 全部抹掉、把 `!!js` 引号风格改掉、把不认识的键重排 —— 一次"设置令牌"不该顺带重写整个
10
10
  // 文件。(这与 `override-blocks.ts` 选择"只算删除区间"是同一条理由。)
11
11
  //
12
- // 边界:只动**本插件 id 那条 insert 子条目**内的 `config:` / `token:` 两行,别的一概不碰。
13
- // 找不到那条行时如实回 `found: false`,由调用方决定怎么报错,绝不"找不到就新建一条"。
12
+ // 边界:只动**本插件 id 那条条目**内的 `config:` / `token:` 两行,别的一概不碰。条目有两种
13
+ // 形状(见 ROW_ID_RE 的注释):0.1.5 补丁里嵌套的 insert 子条目、0.1.7 profile 补丁里顶层
14
+ // 的覆盖条目 —— 同一套相对缩进逻辑都认。找不到那条行时如实回 `found: false`,由调用方
15
+ // 决定怎么报错;唯一的"新建"路径是 0.1.7 的覆盖条目(buildLoaderOverrideEntry,见下)。
14
16
  //
15
17
  // 两个**不改写、只拒绝**的形状(2026-09-19 审计 T-28):
16
18
  // - `config:` 写成 flow style(`config: {token: x}`)或缩进不是 6 空格 ⇒ 本模块的
@@ -22,17 +24,15 @@
22
24
  const yq = (value) => JSON.stringify(value);
23
25
  /** 本插件 loader 行的 id(与 cordis.patch.yml 里那条 insert 子条目一致)。 */
24
26
  export const PLUGIN_LOADER_ID = 'dsh-plugin-tool-management';
25
- const ROW_ID_RE = /^ {4}- id:\s*(\S+)\s*$/;
26
- const CONFIG_RE = /^ {6}config:\s*$/;
27
+ /**
28
+ * 行首 `- id:` 的缩进就是条目的几何原点:0.1.5 的补丁把条目嵌在映射里(4 空格,config
29
+ * 在 6、键在 8);0.1.7 的 profile 覆盖条目在顶层(0 空格,config 在 2、键在 4)。两类形状
30
+ * 用同一套「相对缩进」逻辑定位,readLoaderToken / rewriteConfigKey 都按条目自己的缩进算。
31
+ */
32
+ const ROW_ID_RE = /^( *)- id:\s*(\S+)\s*$/;
27
33
  /** `config:` 的任何别的写法(flow style `config: {…}`、非空值、别的缩进)—— 一律不改写。 */
28
34
  const CONFIG_OTHER_RE = /^\s*config:\s*\S/;
29
- const keyRe = (key) => new RegExp('^ {8}' + key + ':\\s*(.*)$');
30
- const TOKEN_RE = keyRe('token');
31
- const DISABLED_RE = keyRe('tokenDisabled');
32
- /** 该子条目块内的行:缩进 ≥ 6(键),或空行/注释(块间空隙,归前一块)。 */
33
- const inRowBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || /^ {6,}\S/.test(line);
34
- /** config 块内的行:缩进 ≥ 8,或空行/注释。 */
35
- const inConfigBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || /^ {8,}\S/.test(line);
35
+ const keyRe = (indent, key) => new RegExp('^ {' + indent + '}' + key + ':\\s*(.*)$');
36
36
  const unquote = (raw) => {
37
37
  const text = raw.trim();
38
38
  if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
@@ -76,12 +76,23 @@ function parseFlag(raw) {
76
76
  /** 定位插件 loader 行及其 `config:` 块(找不到行时 `start < 0`)。 */
77
77
  function locate(content, pluginId) {
78
78
  const lines = content.split(/\r?\n/);
79
- const start = lines.findIndex((line) => {
80
- const m = ROW_ID_RE.exec(line);
81
- return !!m && m[1] === pluginId;
82
- });
79
+ let start = -1;
80
+ let indent = 0;
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const m = ROW_ID_RE.exec(lines[i]);
83
+ if (m && m[2] === pluginId) {
84
+ start = i;
85
+ indent = m[1].length;
86
+ break;
87
+ }
88
+ }
83
89
  if (start < 0)
84
- return { lines, start: -1, end: -1, configAt: -1, configEnd: -1, configUnrecognised: false, configDuplicated: false };
90
+ return { lines, start: -1, end: -1, indent: 0, configAt: -1, configEnd: -1, configUnrecognised: false, configDuplicated: false };
91
+ // 该子条目块内的行:缩进 ≥ 条目+2(键),或空行/注释(块间空隙,归前一块)。
92
+ const inRowBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || new RegExp('^\\s{' + (indent + 2) + ',}\\S').test(line);
93
+ // config 块内的行:缩进 ≥ 条目+4,或空行/注释。
94
+ const inConfigBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || new RegExp('^\\s{' + (indent + 4) + ',}\\S').test(line);
95
+ const configRe = new RegExp('^ {' + (indent + 2) + '}config:\\s*$');
85
96
  let end = start + 1;
86
97
  while (end < lines.length && inRowBlock(lines[end]))
87
98
  end++;
@@ -89,7 +100,7 @@ function locate(content, pluginId) {
89
100
  let configCount = 0;
90
101
  let configUnrecognised = false;
91
102
  for (let i = start + 1; i < end; i++) {
92
- if (CONFIG_RE.test(lines[i])) {
103
+ if (configRe.test(lines[i])) {
93
104
  configCount += 1;
94
105
  if (configAt < 0)
95
106
  configAt = i;
@@ -106,24 +117,26 @@ function locate(content, pluginId) {
106
117
  while (configEnd < end && inConfigBlock(lines[configEnd]))
107
118
  configEnd++;
108
119
  }
109
- return { lines, start, end, configAt, configEnd, configUnrecognised, configDuplicated: configCount > 1 };
120
+ return { lines, start, end, indent, configAt, configEnd, configUnrecognised, configDuplicated: configCount > 1 };
110
121
  }
111
122
  /** 读插件 loader 行里的 `config.token` / `config.tokenDisabled`(没写 / 没这条行都返回空值)。 */
112
123
  export function readLoaderToken(content, pluginId = PLUGIN_LOADER_ID) {
113
- const { lines, start, end, configAt, configEnd } = locate(content, pluginId);
124
+ const { lines, start, configAt, configEnd, indent } = locate(content, pluginId);
114
125
  if (start < 0)
115
126
  return { found: false, token: '', disabled: false };
116
127
  if (configAt < 0)
117
128
  return { found: true, token: '', disabled: false };
129
+ const tokenRe = keyRe(indent + 4, 'token');
130
+ const disabledRe = keyRe(indent + 4, 'tokenDisabled');
118
131
  let token = '';
119
132
  let disabled = false;
120
133
  for (let i = configAt + 1; i < configEnd; i++) {
121
- const t = TOKEN_RE.exec(lines[i]);
134
+ const t = tokenRe.exec(lines[i]);
122
135
  if (t) {
123
136
  token = unquote(t[1]);
124
137
  continue;
125
138
  }
126
- const d = DISABLED_RE.exec(lines[i]);
139
+ const d = disabledRe.exec(lines[i]);
127
140
  // `null` = 值既不是 true 也不是 false(宿主同样不当成"已关闭"),按未关闭处理。
128
141
  if (d)
129
142
  disabled = parseFlag(d[1]) === true;
@@ -143,8 +156,8 @@ export function readLoaderToken(content, pluginId = PLUGIN_LOADER_ID) {
143
156
  *
144
157
  * @param line - 要写入的整行(含缩进);`null` = 删除该键。
145
158
  */
146
- function rewriteConfigKey(content, key, line, pluginId) {
147
- const { lines, start, end, configAt, configEnd, configUnrecognised, configDuplicated } = locate(content, pluginId);
159
+ function rewriteConfigKey(content, key, value, pluginId) {
160
+ const { lines, start, end, indent, configAt, configEnd, configUnrecognised, configDuplicated } = locate(content, pluginId);
148
161
  if (start < 0)
149
162
  return { found: false };
150
163
  // 写之前先自检:这两种形状一旦落盘就是"DSH 下次起不来"(官方 js-yaml 对重复映射键 throw),
@@ -165,7 +178,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
165
178
  refused: '本插件那条 loader 行里的 `config:` 不是插件认识的写法(flow style 或缩进不是 6 空格),自动改写会插入重复键并导致 DSH 启动失败:请手工把它改回 ` config:` + 缩进 8 空格的键值行,或在配置里手工写 token。',
166
179
  };
167
180
  }
168
- const re = keyRe(key);
181
+ const re = keyRe(indent + 4, key);
169
182
  const keyAt = (() => {
170
183
  if (configAt < 0)
171
184
  return -1;
@@ -174,6 +187,8 @@ function rewriteConfigKey(content, key, line, pluginId) {
174
187
  return i;
175
188
  return -1;
176
189
  })();
190
+ // 要写的整行(含缩进);`value === null` = 删除该键。缩进按条目自己的原点算。
191
+ const line = value === null ? null : ' '.repeat(indent + 4) + key + ': ' + value;
177
192
  // 写之后的第二道自检:不管走哪条分支,产物里 `config:` 只能有一个。宁可原样退回,
178
193
  // 也不让一次界面点击产出一个"DSH 下次起不来"的补丁文件。
179
194
  const commit = (nextContent) => {
@@ -194,7 +209,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
194
209
  // 这个键之外还有别的键吗?(在原行上判断,避免 splice 之后的下标漂移算错)
195
210
  const otherKeys = lines
196
211
  .slice(configAt + 1, configEnd)
197
- .some((row, index) => configAt + 1 + index !== keyAt && /^ {8,}\S/.test(row));
212
+ .some((row, index) => configAt + 1 + index !== keyAt && new RegExp('^\\s{' + (indent + 4) + ',}\\S').test(row));
198
213
  next.splice(keyAt, 1);
199
214
  if (!otherKeys)
200
215
  next.splice(configAt, 1);
@@ -211,7 +226,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
211
226
  return commit(next.join('\n'));
212
227
  }
213
228
  // 没有 config:紧跟 `- id:` 之后插入(映射键的顺序不影响 YAML 语义,位置固定才好断言)。
214
- next.splice(start + 1, 0, ' config:', line);
229
+ next.splice(start + 1, 0, ' '.repeat(indent + 2) + 'config:', line);
215
230
  return commit(next.join('\n'));
216
231
  }
217
232
  /**
@@ -223,7 +238,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
223
238
  * 否则给出新全文与"是否真的改了"。
224
239
  */
225
240
  export function applyLoaderToken(content, token, pluginId = PLUGIN_LOADER_ID) {
226
- return rewriteConfigKey(content, 'token', token === null ? null : ' token: ' + yq(token), pluginId);
241
+ return rewriteConfigKey(content, 'token', token === null ? null : yq(token), pluginId);
227
242
  }
228
243
  /**
229
244
  * 打开(`true`)或关掉(`false`)令牌功能 —— **不动 `config.token` 本身**。
@@ -234,5 +249,15 @@ export function applyLoaderToken(content, token, pluginId = PLUGIN_LOADER_ID) {
234
249
  * @param disabled - `true` 写开关;`false` / `null` 删掉该键。
235
250
  */
236
251
  export function applyLoaderTokenDisabled(content, disabled, pluginId = PLUGIN_LOADER_ID) {
237
- return rewriteConfigKey(content, 'tokenDisabled', disabled === true ? ' tokenDisabled: true' : null, pluginId);
252
+ return rewriteConfigKey(content, 'tokenDisabled', disabled === true ? 'true' : null, pluginId);
253
+ }
254
+ /**
255
+ * 0.1.7 bundle 挂载下的令牌落点:profile 补丁里的**覆盖条目**(官方 `applyEntryPatches`
256
+ * 的 id-targeted override —— 不带 name / insert、只带 id + config,宿主在 bundle 层之后
257
+ * 应用它,整体替换那条 loader 的 `config`;bundle 条目没有 config,替换零丢失)。
258
+ * 注意:同 id 的 **insert** 条目才会重复挂载、启动失败;覆盖条目是官方支持的改法,
259
+ * 目标不存在的树只是逐条警告跳过(一份 overlay 打多个表面,不必每棵树都命中)。
260
+ */
261
+ export function buildLoaderOverrideEntry(token, pluginId = PLUGIN_LOADER_ID) {
262
+ return '- id: ' + pluginId + '\n config:\n token: ' + yq(token);
238
263
  }