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
package/lib/index.js CHANGED
@@ -18,8 +18,9 @@ import { createHistoryDomain } from './sessions/history.js';
18
18
  import { createSkillsService, pluginLog } from './skills/service.js';
19
19
  import { renameWithRetry } from './skills/core.js';
20
20
  import { createPromptsService } from './prompts/service.js';
21
+ import { createQuickPromptsService } from './prompts/quick-service.js';
21
22
  import { isInsideRootResolved } from './paths.js';
22
- import { DEFAULT_PROFILE_NAME, PROFILE_CANDIDATES } from './host-names.js';
23
+ import { DEFAULT_PROFILE_NAME, MCP_TOOL_PREFIX, PROFILE_CANDIDATES, serverNameCandidates, splitMcpToolName } from './host-names.js';
23
24
  import { createMemoriesService } from './memories/service.js';
24
25
  import { createArchiveEngine } from './memories/archive-engine.js';
25
26
  import { createSnapshotOps } from './ops/snapshot.js';
@@ -29,14 +30,19 @@ import { runStateDoctor } from './ops/state-doctor.js';
29
30
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
30
31
  import { createSubagentCatalog } from './subagents/catalog.js';
31
32
  import { createSkillCatalog } from './skills/catalog.js';
32
- import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
33
+ import { createContextInjector, subagentDepthOf, } from './context-inject.js';
33
34
  import { isApprovalNever } from './approval-policy.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';
35
- import { normalizeSceneSettings } from './scene-settings.js';
35
+ import { DEFAULT_HIDDEN_TOOLS, FACTORY_TOOL_TABLE_PRESET, normalizeToolTableSettings, } from './tools/table.js';
36
+ import { createToolTableSwitch } from './tool-table-switch.js';
37
+ import { createSettingsSidecars } from './settings-sidecars.js';
38
+ // 改名 / 进场景 / 存档案的联动(op 重包,2026-10-01 从本文件 apply 闭包整段抽出)。
39
+ import { installSceneLinkage } from './scene-linkage.js';
40
+ // 访问令牌的宿主侧配置读写(2026-10-01 从本文件 apply 闭包整段抽出,依赖显式传参)。
41
+ import { createTokenConfig } from './token-config.js';
36
42
  import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
37
43
  import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
38
44
  import { reachNoticeForAgent } from './compat/preset-reach.js';
39
- import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
45
+ import { installHttpApi } from './http-api.js';
40
46
  import { createMcpManager } from './mcp/manager.js';
41
47
  // 确认卡回显"新 URL"时要打码(查询串里的凭据不该明文进卡片),口径与列表视图同一份实现。
42
48
  import { maskUrlQuery } from './mcp/secret-guard.js';
@@ -44,14 +50,15 @@ import { createCandidates } from './scenes/candidates.js';
44
50
  // 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
45
51
  // 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
46
52
  export { normalizeKnownTools, countEnabledTools } from './mcp/manager.js';
47
- import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken } from './mcp/loader-token.js';
48
53
  import { hubBackupDir, hubPath, migrateHubLayoutSync, relocateEntries } from './hub.js';
49
54
  // 分域的 HTTP ops(2026-09-19 从本文件 handlers 表抽出,依赖显式传参)。
50
55
  import { buildCompatOps } from './ops/compat.js';
51
56
  import { buildPromptOps } from './ops/prompts.js';
57
+ import { buildQuickPromptOps } from './ops/quick-prompts.js';
52
58
  import { buildSessionOps } from './ops/sessions.js';
53
59
  // HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
54
- import { createAccessToken, createOpWhitelist, createSceneLock, installHandlerGuards } from './request-gate.js';
60
+ import { createAccessToken, createFrozenGate, createOpWhitelist, createSceneLock, guardModelOp, guardModelOps, installHandlerGuards } from './request-gate.js';
61
+ // `isReadCall` 随 HTTP 路由一起搬走了(写门禁与审计流水都读同一份登记表判据)。
55
62
  import { auditOpRegistry, auditProblems } from './op-registry.js';
56
63
  import { buildSceneSyncOps } from './ops/scene-sync.js';
57
64
  import { buildCandidateOps } from './ops/candidates.js';
@@ -205,6 +212,13 @@ export default {
205
212
  return p.home + sep + 'AGENTS.md';
206
213
  },
207
214
  });
215
+ // ---------- 快捷提示词(hub/quick-prompts/)----------
216
+ // 与上面的预设库**两份存储**,不是同一份加个标记位:`list()` 只扫自己目录,所以快捷词
217
+ // 天然进不了「应用 / 场景绑定 / 注入 / 模型工具」这四条只认 hub/prompts/ 的路径。
218
+ // 理由见 ./prompts/quick-service.ts 头注。
219
+ const quickPromptsDir = hubPath('quick-prompts');
220
+ const quickPromptsService = createQuickPromptsService({ dir: quickPromptsDir });
221
+ const quickPromptOps = buildQuickPromptOps(quickPromptsService);
208
222
  // ---------- rules(规则/记忆,v0.4)----------
209
223
  // 记忆真源 $DSH_HOME/tool-management/memories/<场景>/<name>.md(仅用户级,D1)。
210
224
  // **场景是显式记录**(memories-index.json 的 scenes 切片):含保留场景 `global`(界面「全局」,
@@ -221,23 +235,37 @@ export default {
221
235
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
222
236
  ? { maxBytes: Number(config.rulesMaxBytes) }
223
237
  : {}),
238
+ // 全局 AGENTS.md 的**宿主实际读的那一份**(profile home 下,与上面 `getGlobalAgentsMdPath`
239
+ // 同源)。`readGlobalAgentsMdSync` 的去重/兜底必须读它 —— `resolveDshHome()` 是另一条
240
+ // 独立来源(见 `ensurePaths` 的注释「不合并是有意的」),两边不同时会取到另一个文件、
241
+ // 去重永远不相等 → 同一份预设正文注入两遍(审查 P2-19)。
242
+ // 惰性求值:`cached` 在下面的 path discovery 段才声明(`let`),而本箭头函数只在 op
243
+ // 被调用时才求值 —— 那时 `ensurePaths()` 早已跑过。外面这层 `try` 是防**将来**有人在
244
+ // apply 中途同步调用 memories op 那种时序(`let` 的 TDZ 会抛 ReferenceError)。
245
+ globalAgentsMdPath: () => {
246
+ try {
247
+ const p = cached;
248
+ if (!p)
249
+ return null;
250
+ const sep = p.home.indexOf('\\') >= 0 ? '\\' : '/';
251
+ return p.home + sep + 'AGENTS.md';
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ },
224
257
  });
225
258
  // 场景记忆段的注册/生命周期由下面的注入通道统一管(memoriesService.memoryText / promptText)。
226
259
  // ---------- 场景档案引擎(设计 §2)----------
227
260
  // deps 把既有写通道(MCP 单工具启停 / 技能启停)注入引擎;引擎自身串行,
228
261
  // 状态机与纯逻辑在 ./memories/archive*.ts。mcpmToolEnabled / readDisabledTools
229
262
  // 为函数声明(提升),此处引用安全。
230
- const toolKeyParts = (toolName) => {
231
- if (!toolName.startsWith('mcp__'))
232
- return null;
233
- const rest = toolName.slice(5);
234
- const i = rest.indexOf('__');
235
- if (i <= 0)
236
- return null;
237
- const server = rest.slice(0, i);
238
- const tool = rest.slice(i + 2);
239
- return server && tool ? { key: server + '/' + tool, server, tool } : null;
240
- };
263
+ // MCP 工具全名 → `{ server, tool }`。**切分规则只有一套**:`splitMcpToolName`
264
+ // (`host-names.ts`),与执行侧守卫 `mcp.isToolDisabled` 走的是同一个函数(2026-09-30 审查 N1)。
265
+ // 这里只负责把**候选 serverName 集合**传进去 —— 集合不同是切分结论不同的另一个来源,
266
+ // 所以集合也用同一个 builder(`serverNameCandidates`)从**同一批表**(停用表 ∪ 已知工具缓存)
267
+ // 取并集,不在这里手写 `Object.keys`。
268
+ const toolKeyParts = (toolName, serverNames) => splitMcpToolName(toolName, serverNames);
241
269
  /**
242
270
  * 一次扫描同时给出三样东西(避免为了三份视图各扫一遍磁盘):
243
271
  * - `states`:**真实生效**的技能 `<rootKey>/<name>` → boolean;
@@ -336,33 +364,41 @@ export default {
336
364
  },
337
365
  applyToolTableHidden: applyToolTableHiddenNow,
338
366
  loadSlice: async () => ({ ...(await memoriesService.readArchiveSlice()) }),
339
- saveSlice: (slice) => memoriesService.patchIndex(slice),
340
- configuredServers: async () => {
341
- const r = await mcp.mcpmListView();
342
- const out = [];
343
- for (const row of ((r && r.rows) || [])) {
344
- const n = String((row && row.serverName) || '');
345
- if (n && out.indexOf(n) < 0)
346
- out.push(n);
347
- }
348
- return out;
349
- },
367
+ // `fields` 必须原样透传:它决定"本次只写哪几片",是审查 P2-3 里防丢更新的关键。
368
+ saveSlice: (slice, fields) => memoriesService.patchIndex(slice, fields),
369
+ saveArchive: (scene, archive) => memoriesService.saveArchive(scene, archive),
370
+ // 与 `serverKnownTools` 用同一个出口:都是「补丁里配了哪些 serverName」。
371
+ // 早先这里走 `mcpmListView()` —— 那份会顺带枚举工具 schema 并回写「已知工具」缓存,
372
+ // 而档案引擎只要名字(2026-09-30 审查 N1 顺带收窄)。
373
+ configuredServers: () => mcp.configuredServerNames(),
350
374
  serverKnownTools: async () => {
351
375
  const out = {};
352
376
  const raw = await mcp.readDisabledTools();
353
377
  for (const [server, list] of Object.entries(raw))
354
378
  out[server] = list.filter((t) => t !== '*');
355
379
  // 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
356
- for (const [server, list] of Object.entries(await mcp.readKnownMcpTools())) {
380
+ const known = await mcp.readKnownMcpTools();
381
+ for (const [server, list] of Object.entries(known)) {
357
382
  out[server] = [...new Set([...(out[server] || []), ...list.map((item) => item.name)])].sort();
358
383
  }
384
+ // 候选集合必须在**往 out 里补 live 工具之前**取:补进去的键是从 schema 名切出来的,
385
+ // 拿它当候选就成了循环论证(切错了也照样"命中")。
386
+ //
387
+ // 三个来源里,补丁那份(`configuredServerNames`)是唯一与切分结果无关的 —— 停用表的键
388
+ // 来自界面勾选、缓存键来自上一次切分,单靠它们会锁死错误结论(审查 N1 / R3)。
389
+ let configured = [];
390
+ try {
391
+ configured = await mcp.configuredServerNames();
392
+ }
393
+ catch { /* 补丁读不到 → 退化为另两份 */ }
394
+ const serverNames = serverNameCandidates(configured, raw, known);
359
395
  let schemas = [];
360
396
  try {
361
397
  schemas = await tools.schemas();
362
398
  }
363
399
  catch { /* 无 live 工具 → 仅停用表 + 缓存 */ }
364
400
  for (const s of schemas) {
365
- const p = toolKeyParts(String((s && s.name) || ''));
401
+ const p = toolKeyParts(String((s && s.name) || ''), serverNames);
366
402
  if (!p)
367
403
  continue;
368
404
  const list = out[p.server] || (out[p.server] = []);
@@ -530,6 +566,26 @@ export default {
530
566
  await subagentService.enabledStore.setEnabled(on, true);
531
567
  void subagentCatalog.refresh();
532
568
  },
569
+ // 快捷提示词(0.18.0):档案定义了 quickPrompts 段时,进入场景 = 把每一条的开关置成
570
+ // 「在不在这份清单里」,退出按快照还原。读写的就是提示词页那颗开关的同一份存储
571
+ // (`hub/quick-prompts/<id>/meta.json`),不在别处再算一套状态。
572
+ knownQuickPrompts: async () => {
573
+ const r = await quickPromptsService.list();
574
+ return new Set(r.ok === true ? r.prompts.map((p) => p.id) : []);
575
+ },
576
+ quickPromptStates: async () => {
577
+ const r = await quickPromptsService.list();
578
+ if (r.ok !== true)
579
+ throw new Error(r.error);
580
+ return Object.fromEntries(r.prompts.map((p) => [p.id, p.enabled !== false]));
581
+ },
582
+ applyQuickPromptSwitches: async (switches) => {
583
+ // 已不存在的 id 由 setEnabled 自己回 ok:false —— 这里**不**把它抛出去:退出场景时
584
+ // "还原一条已被删掉的快捷词"没有可写的地方,让它报错会把整次退出卡住
585
+ // (与 mcpServerRowsToRestore「现在已不存在的行直接跳过」同一口径)。
586
+ for (const s of switches)
587
+ await quickPromptsService.setEnabled(s.id, s.enabled);
588
+ },
533
589
  // 记忆 id 全集。引擎已不消费(memories 段 P5 起废弃),实现保留以维持接口形状。
534
590
  knownMemoryIds: async () => {
535
591
  const r = await memoriesService.ops['rules-list']({});
@@ -550,156 +606,20 @@ export default {
550
606
  .filter((s) => s.active)
551
607
  .map((s) => slice.archives[s.name]?.subagents ?? []);
552
608
  };
553
- // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
554
- // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
555
- // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
556
- // (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
557
- // 选择时铺(见下面的 `toolTableSettingsFrom`)。
558
- //
559
- // 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
560
- // 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
561
- const TOOL_TABLE_FILE = 'tool-table.json';
562
- const TOOL_TABLE_TTL_MS = 3000;
563
- /** 注册时量到的工具体积(只记真进了表的那些;量的是 register 收到的整份定义)。 */
564
- const toolTableSizes = new Map();
565
- function recordToolSize(def) {
566
- try {
567
- const name = String(def.name ?? '');
568
- if (name === '')
569
- return;
570
- // 与宿主同一口径(dsh-token-meter 的 estimateToolsTokens 就是把 tools 整份
571
- // JSON.stringify 后除以 4),所以这里量整份定义,不只量 description。
572
- toolTableSizes.set(name, JSON.stringify(def).length);
573
- }
574
- catch { /* 量不出来就不记:它只影响界面上的估算数字 */ }
575
- }
576
- let toolTableCache = null;
577
- async function readToolTableSettings(force = false) {
578
- if (toolTableCache && !force && Date.now() - toolTableCache.at < TOOL_TABLE_TTL_MS)
579
- return toolTableCache.value;
580
- await ensurePaths();
581
- const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
582
- // 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
583
- // 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
584
- // 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
585
- // 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
586
- // 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
587
- // 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
588
- const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
589
- const value = migrated.settings;
590
- if (migrated.changed) {
591
- try {
592
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
593
- }
594
- catch { /* 回写失败:本次仍按迁移后的值生效 */ }
595
- }
596
- const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
597
- toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
598
- // 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
599
- // 自己重排,它们只是"下次问的时候读到新值";把限制摘掉/装上这一步必须有人做。
600
- // (`mcp` 在 apply 里是同步赋值、本函数只可能在自己的 await 之后回到这里,所以到得了。)
601
- if (changed) {
602
- try {
603
- mcp.scheduleToolRestrictions();
604
- }
605
- catch { /* 兜底:拿不到 manager 就等下一次 tools/change */ }
606
- }
607
- return value;
608
- }
609
- /**
610
- * 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
611
- * **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
612
- * 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
613
- * 工具凭空消失)。
614
- */
615
- function toolTableHidden() {
616
- if (toolTableCache === null) {
617
- void readToolTableSettings().catch(() => { });
618
- return EMPTY_TOOL_SET;
619
- }
620
- return toolTableCache.off;
621
- }
622
- const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
623
- const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
624
- /** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
625
- async function writeToolTableSettings(next) {
626
- await ensurePaths();
627
- // 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
628
- // 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
629
- return withWriteLock(async () => {
630
- try {
631
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
632
- }
633
- catch (e) {
634
- return { ok: false, error: '设置保存失败: ' + message(e) };
635
- }
636
- toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
637
- return { ok: true };
638
- });
639
- }
640
- /**
641
- * 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
642
- *
643
- * `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
644
- * `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
645
- * 不给删除键,词典出它的名字。
646
- */
647
- const toolTablePayload = (value) => ({
648
- ok: true, hidden: value.hidden, presets: value.presets,
649
- defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
609
+ // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)→ ./tool-table-switch.ts ----------
610
+ // 那 134 行整段搬进同目录模块(侧车 TTL 缓存、三条写路径、`tool-table` op 正文),闭包名字
611
+ // 走 deps。工具体积表 toolTableSizes 按**同一个 Map 对象**交回来:注册口(defineTool 那层)
612
+ // 在外面写它、op 与场景写路径读它 —— 交回对象而不是复制,所以母文件这几处一个字都不用改。
613
+ // 这段仍然留在两个目录之前:目录里那句「用 `X` 查」的提示要读 toolTableHidden —— 搬走的是
614
+ // 这段的正文,不是这段的顺序(顺序就是依赖顺序)。
615
+ // mcp / skillCatalog / subagentCatalog 在本块之后才建,按 getter 交进去:取值发生在真正要
616
+ // 用的那一刻,与原闭包同形,既不撞 TDZ 也不动搬走的那些行。
617
+ const { toolTableSizes, recordToolSize, readToolTableSettings, toolTableHidden, writeToolTableSettings, toolTableReport, toolTableOp, } = createToolTableSwitch({
618
+ ensurePaths, readJsonFile, writeJsonFile, withWriteLock, message, EMPTY_TOOL_SET,
619
+ get mcp() { return mcp; },
620
+ get skillCatalog() { return skillCatalog; },
621
+ get subagentCatalog() { return subagentCatalog; },
650
622
  });
651
- /** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
652
- const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
653
- async function toolTableOp(args) {
654
- const current = await readToolTableSettings();
655
- if (!args || args.set !== true) {
656
- // 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
657
- // 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
658
- if (args && args.presetSave !== undefined) {
659
- const name = presetNameOf(args.presetSave);
660
- if (name === '')
661
- return { ok: false, code: 'nameEmpty' };
662
- const added = addPreset(current, name, current.hidden);
663
- // 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
664
- if (added.exists)
665
- return { ok: false, code: 'nameTaken', name };
666
- if (current.presets.length >= PRESET_MAX_COUNT) {
667
- return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
668
- }
669
- const saved = await writeToolTableSettings(added.settings);
670
- if (!saved.ok)
671
- return saved;
672
- return toolTablePayload(added.settings);
673
- }
674
- if (args && args.presetDelete !== undefined) {
675
- const name = presetNameOf(args.presetDelete);
676
- const dropped = dropPreset(current, name);
677
- if (!dropped.changed)
678
- return { ok: false, code: 'notFound', name };
679
- const saved = await writeToolTableSettings(dropped.settings);
680
- if (!saved.ok)
681
- return saved;
682
- return toolTablePayload(dropped.settings);
683
- }
684
- return toolTablePayload(current);
685
- }
686
- // 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
687
- // ("names unknown global tool")。注册失败的工具本来也不在表里。
688
- const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
689
- .filter((name) => toolTableSizes.has(name));
690
- const next = { hidden, presets: current.presets };
691
- const saved = await writeToolTableSettings(next);
692
- if (!saved.ok)
693
- return saved;
694
- // 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
695
- // 目录的 refresh 会把新的一句话注入出去 —— 内容变了才重发,这是正确行为。
696
- mcp.scheduleToolRestrictions();
697
- await Promise.all([
698
- skillCatalog.refresh().catch(() => { }),
699
- subagentCatalog.refresh().catch(() => { }),
700
- ]);
701
- return toolTablePayload(next);
702
- }
703
623
  // ---------- 「有官方等价物 ⇒ 我们让位」----------
704
624
  // 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
705
625
  // 件事:按名字给正文。标准类预设下它在场,第二份就是白付的 174 tok/轮。
@@ -757,90 +677,16 @@ export default {
757
677
  state: () => skillsService.ops['skill-state']({}),
758
678
  listToolVisible: () => !toolTableHidden().has('skill_manager_list'),
759
679
  });
760
- // 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
761
- // - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
762
- // 下是否仍然注入。默认 false = 跟随预设。
763
- // - domains:各域开关(任何预设下都生效;界面五个勾选)。
764
- // 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
765
- const INJECT_SETTINGS_FILE = 'inject-settings.json';
766
- const INJECT_SETTINGS_TTL_MS = 3000;
767
- let injectSettingsCache = null;
768
- async function readInjectSettings(force = false) {
769
- if (injectSettingsCache && !force && Date.now() - injectSettingsCache.at < INJECT_SETTINGS_TTL_MS)
770
- return injectSettingsCache.value;
771
- await ensurePaths();
772
- const raw = await readJsonFile(hubPath(INJECT_SETTINGS_FILE));
773
- injectSettingsCache = { at: Date.now(), value: normalizeInjectSettings(raw) };
774
- return injectSettingsCache.value;
775
- }
776
- /** 同步快照(注入器取用);还没加载时先返回默认值,并顺手预热一次。 */
777
- function injectSettingsSync() {
778
- if (injectSettingsCache)
779
- return injectSettingsCache.value;
780
- void readInjectSettings().catch(() => { });
781
- return DEFAULT_INJECT_SETTINGS;
782
- }
783
- /** 边界提示要的注入设置快照(总开关 + 各域开关)。 */
784
- const injectNoticeOptions = () => {
785
- const s = injectSettingsSync();
786
- return { underSuppressingPresets: s.underSuppressingPresets, domains: s.domains };
787
- };
788
- async function injectSettingsOp(args) {
789
- const current = await readInjectSettings();
790
- if (!args || args.set !== true)
791
- return { ok: true, settings: current };
792
- const next = normalizeInjectSettings({
793
- underSuppressingPresets: typeof args.underSuppressingPresets === 'boolean'
794
- ? args.underSuppressingPresets
795
- : current.underSuppressingPresets,
796
- domains: { ...current.domains, ...(args.domains && typeof args.domains === 'object' ? args.domains : {}) },
797
- });
798
- await ensurePaths();
799
- return withWriteLock(async () => {
800
- try {
801
- await writeJsonFile(hubPath(INJECT_SETTINGS_FILE), next);
802
- }
803
- catch (e) {
804
- return { ok: false, error: '设置保存失败: ' + message(e) };
805
- }
806
- injectSettingsCache = { at: Date.now(), value: next };
807
- return { ok: true, settings: next };
808
- });
809
- }
810
- // 场景页的界面设置(侧车 `scene-settings.json`):目前只有一项 —— 进入场景前要不要先弹
811
- // 那张「会改什么」的卡。它纯粹是界面提示,所以**不进场景冻结**(锁着场景的人在场景页
812
- // 照样能关掉提醒,那与五个管理域的只读无关)。
813
- const SCENE_SETTINGS_FILE = 'scene-settings.json';
814
- const SCENE_SETTINGS_TTL_MS = 3000;
815
- let sceneSettingsCache = null;
816
- async function readSceneSettings(force = false) {
817
- if (sceneSettingsCache && !force && Date.now() - sceneSettingsCache.at < SCENE_SETTINGS_TTL_MS)
818
- return sceneSettingsCache.value;
819
- await ensurePaths();
820
- const raw = await readJsonFile(hubPath(SCENE_SETTINGS_FILE));
821
- const value = normalizeSceneSettings(raw);
822
- sceneSettingsCache = { at: Date.now(), value };
823
- return value;
824
- }
825
- async function sceneSettingsOp(args) {
826
- const current = await readSceneSettings();
827
- if (!args || args.set !== true)
828
- return { ok: true, settings: current };
829
- const next = normalizeSceneSettings({
830
- enterPreview: typeof args.enterPreview === 'boolean' ? args.enterPreview : current.enterPreview,
831
- });
832
- await ensurePaths();
833
- return withWriteLock(async () => {
834
- try {
835
- await writeJsonFile(hubPath(SCENE_SETTINGS_FILE), next);
836
- }
837
- catch (e) {
838
- return { ok: false, error: '设置保存失败: ' + message(e) };
839
- }
840
- sceneSettingsCache = { at: Date.now(), value: next };
841
- return { ok: true, settings: next };
842
- });
843
- }
680
+ // 三份界面设置侧车(inject / scene / slash)拆到 ./settings-sidecars.ts:读走 3 秒 TTL 缓存,
681
+ // 写共用上面那条串行写锁。工厂只在这里调用一次,三份缓存仍是「每次 apply 一份」。
682
+ // 传的是 apply 作用域里那几个函数声明的引用(提升已初始化),不是快照值。
683
+ const { readInjectSettings, injectSettingsSync, injectNoticeOptions, injectSettingsOp, readSceneSettings, sceneSettingsOp, readSlashSettings, slashSettingsOp, } = createSettingsSidecars({
684
+ ensurePaths,
685
+ readJsonFile,
686
+ writeJsonFile,
687
+ withWriteLock,
688
+ message,
689
+ });
844
690
  // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的各域文本。
845
691
  // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
846
692
  let contextInjectorLive = null;
@@ -929,6 +775,14 @@ export default {
929
775
  iso: new Date().toISOString(),
930
776
  version: PKG_VERSION,
931
777
  injects: [...INJECT_SERVICES],
778
+ // 宿主**进程身份**(2026-09-30 审查 §5 F1):插件与宿主同进程,所以 `pid` 就是宿主的 pid,
779
+ // 而 `hostStartedAt` 是本进程的启动时刻(`Date.now() - uptime`)。有了这两个值,doctor
780
+ // 才能把「这份心跳是不是**这一轮**宿主写的」变成一条可计算的结论:
781
+ // pid 已经不存在 → 宿主自那以后重启过(或那次运行已结束);
782
+ // pid 还在 → 心跳来自当前正在跑的宿主进程,插件这一轮**确实挂上了**。
783
+ // 此前只有时间戳,用户必须自己记得"我什么时候重启的"才能解读它。
784
+ pid: process.pid,
785
+ hostStartedAt: Date.now() - Math.round(process.uptime() * 1000),
932
786
  }).catch((e) => { ctx.logger?.warn?.('mount heartbeat write failed: ' + message(e)); });
933
787
  // B3 符号断言:probe.ts 的 unwrap 用硬编码的 `Symbol.for('cordis.original')`,与 cordis
934
788
  // 导出的 `symbols.original` 必须是同一个符号。不是的话,探针会把代理当原始对象、身份判定失真。
@@ -981,158 +835,18 @@ export default {
981
835
  return result;
982
836
  };
983
837
  }
984
- /**
985
- * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
986
- * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
987
- * 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
988
- */
989
- async function rebindSubagentInArchives(from, to) {
990
- try {
991
- const slice = await memoriesService.readArchiveSlice();
992
- const archives = { ...(slice.archives || {}) };
993
- let changed = 0;
994
- for (const [scene, archive] of Object.entries(archives)) {
995
- const list = archive.subagents;
996
- if (!Array.isArray(list) || !list.includes(from))
997
- continue;
998
- archives[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
999
- changed++;
1000
- }
1001
- if (changed)
1002
- await memoriesService.patchIndex({ archives });
1003
- return changed;
1004
- }
1005
- catch {
1006
- return 0;
1007
- }
1008
- }
1009
- /**
1010
- * 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
1011
- * 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
1012
- * 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
1013
- */
1014
- async function renameSubagentInSnapshot(from, to) {
1015
- try {
1016
- const slice = await memoriesService.readArchiveSlice();
1017
- const mode = slice && slice.mode;
1018
- const snapshot = mode && mode.snapshot;
1019
- if (!snapshot)
1020
- return;
1021
- let changed = false;
1022
- const next = { ...snapshot };
1023
- if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
1024
- const all = {};
1025
- for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
1026
- all[k === from ? to : k] = v;
1027
- if (k === from)
1028
- changed = true;
1029
- }
1030
- next.subagentsAll = all;
1031
- }
1032
- for (const field of ['subagents', 'subagentsOn']) {
1033
- const list = snapshot[field];
1034
- if (Array.isArray(list) && list.includes(from)) {
1035
- next[field] = list.map((n) => (n === from ? to : n));
1036
- changed = true;
1037
- }
1038
- }
1039
- if (changed)
1040
- await memoriesService.patchIndex({ mode: { ...mode, snapshot: next } });
1041
- }
1042
- catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
1043
- }
1044
- const baseSubagentUpdate = subagentService.ops['subagent-update'];
1045
- if (typeof baseSubagentUpdate === 'function') {
1046
- subagentService.ops['subagent-update'] = async (args) => {
1047
- const res = await baseSubagentUpdate(args);
1048
- if (res && res.ok !== false && res.renamedFrom) {
1049
- const from = String(res.renamedFrom);
1050
- const to = String(res.name);
1051
- void rebindSubagentInArchives(from, to);
1052
- void renameSubagentInSnapshot(from, to);
1053
- }
1054
- return res;
1055
- };
1056
- }
1057
- // 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
1058
- // 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
1059
- const baseSceneModeSet = archiveService.ops['scene-mode-set'];
1060
- if (typeof baseSceneModeSet === 'function') {
1061
- archiveService.ops['scene-mode-set'] = async (args) => {
1062
- // 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
1063
- // 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
1064
- // 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
1065
- try {
1066
- const slice = await memoriesService.readArchiveSlice();
1067
- const current = slice.mode && slice.mode.scene;
1068
- if (current && args && 'scene' in (args || {})) {
1069
- const target = args.scene == null ? null : String(args.scene).trim() || null;
1070
- if (target !== current) {
1071
- const locked = await lockedSceneNames();
1072
- if (locked.includes(current)) {
1073
- return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
1074
- }
1075
- }
1076
- }
1077
- }
1078
- catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
1079
- const res = await baseSceneModeSet(args);
1080
- if (res && res.ok !== false) {
1081
- // 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
1082
- // 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
1083
- void subagentCatalog.refresh().catch(() => { });
1084
- void mcp.stateCatalog.refresh().catch(() => { });
1085
- }
1086
- return res;
1087
- };
1088
- }
1089
- /**
1090
- * 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
1091
- * (与进入场景时同一口径,见 archive-engine)——
1092
- * 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
1093
- * 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
1094
- * (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
1095
- */
1096
- const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
1097
- if (typeof baseSceneArchiveSave === 'function') {
1098
- archiveService.ops['scene-archive-save'] = async (args) => {
1099
- const res = await baseSceneArchiveSave(args);
1100
- try {
1101
- const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
1102
- const slice = await memoriesService.readArchiveSlice();
1103
- const mode = slice.mode;
1104
- if (mode && mode.scene === res.scene) {
1105
- const every = (await subagentService.list()).map((p) => p.name);
1106
- const unbound = every.filter((n) => bound.indexOf(n) < 0);
1107
- const toOn = await subagentService.enabledStore.disabledAmong(bound);
1108
- const toOff = await subagentService.enabledStore.enabledAmong(unbound);
1109
- if (toOn.length)
1110
- await subagentService.enabledStore.setEnabled(toOn, true);
1111
- if (toOff.length)
1112
- await subagentService.enabledStore.setEnabled(toOff, false);
1113
- if (toOn.length || toOff.length)
1114
- void subagentCatalog.refresh();
1115
- const snapshot = mode.snapshot;
1116
- // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
1117
- // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
1118
- // archive-engine 的 restoreSnapshot 兼容分支)。
1119
- if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
1120
- const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
1121
- const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
1122
- for (const n of toOn)
1123
- if (backOn.indexOf(n) < 0)
1124
- backOn.push(n);
1125
- for (const n of toOff)
1126
- if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
1127
- offList.push(n);
1128
- await memoriesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
1129
- }
1130
- }
1131
- }
1132
- catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
1133
- return res;
1134
- };
1135
- }
838
+ // ---------- 改名 / 进场景 / 存档案的联动(op 重包) ----------
839
+ // 正文 2026-10-01 整段搬到 ./scene-linkage.ts(一字未改,只把七个 apply 作用域名字改走
840
+ // deps)。调用点位置原样不动 —— 重包的是那四个 service 对象上的 ops 表,而下游(handlers
841
+ // 表、工具依赖表)取的是**当时那份函数**:包晚了,联动对它们就不存在。
842
+ // 两个**定义在本调用之后**的名字用 getter 传:`mcp` 在 MCP 管理域那一段才建、
843
+ // `lockedSceneNames` 在场景锁那一层才解构 —— 直接放进对象字面量会当场踩 TDZ,
844
+ // 而取用它们的时机是请求期,那时两个 const 都已初始化完。
845
+ installSceneLinkage({
846
+ memoriesService, subagentService, archiveService, quickPromptOps, subagentCatalog,
847
+ get mcp() { return mcp; },
848
+ get lockedSceneNames() { return lockedSceneNames; },
849
+ });
1136
850
  // HTTP 写 / 敏感 op 白名单(2026-09-19 抽到 ./request-gate.ts):四个 service 自报的
1137
851
  // writeOps,加上本文件内联域(mcpm-* / skill-open / agentsmd-* / history-*)。
1138
852
  const { WRITE_OPS, SENSITIVE_OPS } = createOpWhitelist({
@@ -1376,10 +1090,22 @@ export default {
1376
1090
  // learned this the hard way: a bad edit to cordis.patch.yml can stop DSH
1377
1091
  // from booting, so the pre-write file must stay recoverable without git.
1378
1092
  const KEEP_PATCH_BACKUPS = 5;
1093
+ /**
1094
+ * 备份名里的时间戳:**到毫秒 + 进程内序号**(定宽)。
1095
+ *
1096
+ * 为什么不能只到秒(2026-09-30 审查 P2-5):同一次操作里常常连写两遍补丁(`mcpm-set-enabled`
1097
+ * 的强制写 + 恢复写、整理补丁的批量写),两次落在同一秒时**后一份备份会覆盖前一份** ——
1098
+ * `KEEP_PATCH_BACKUPS=5` 于是不是"留 5 个历史状态"而是"留 5 个秒",最坏情况下唯一能回滚的
1099
+ * 中间副本被挤掉。序号保证同一毫秒内也不撞名。
1100
+ *
1101
+ * 两段都是**定宽**,所以按名字排序仍然等于按时间排序(`listPatchBackups` 的剪枝依赖这一点)。
1102
+ */
1103
+ let backupSeq = 0;
1379
1104
  function backupStamp() {
1380
1105
  const d = new Date();
1381
- const p = (n) => String(n).padStart(2, '0');
1382
- return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
1106
+ const p = (n, w = 2) => String(n).padStart(w, '0');
1107
+ backupSeq = (backupSeq + 1) % 1000;
1108
+ return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + p(d.getMilliseconds(), 3) + p(backupSeq, 3);
1383
1109
  }
1384
1110
  // 改 patch 前的自动备份:写在 `hub/backups/`(不再堆在 `$DSH_HOME` 根下)。
1385
1111
  // 两个 patch 都叫 `cordis.patch.yml`(全局 + `profiles/<名字>/`),所以备份名里带上层级,
@@ -1415,7 +1141,10 @@ export default {
1415
1141
  * 用它列目录会得到 0 份。它的用途是启动期搬运旧文件,语义不同,别混。
1416
1142
  */
1417
1143
  function isBackupFileName(name) {
1418
- return /^cordis\.patch\.yml(?:\.[A-Za-z0-9._-]+)?\.bak-\d{8}-\d{6}$/.test(basename(String(name || '')));
1144
+ // 两代时间戳都认:老名字到秒(`\d{6}`),0.17.0 起加了毫秒与序号(共 `\d{12}`)。
1145
+ // 只认新格式会让**存量备份**在清单与自动剪枝里集体消失 —— 它们既不再被剪掉,
1146
+ // 也不再出现在「清理旧备份」里,等于把明文密钥副本永久留在盘上。
1147
+ return /^cordis\.patch\.yml(?:\.[A-Za-z0-9._-]+)?\.bak-\d{8}-\d{6}(?:\d{6})?$/.test(basename(String(name || '')));
1419
1148
  }
1420
1149
  /**
1421
1150
  * 一份备份属于**哪一份 patch**(备份名里的层级标签)。
@@ -1562,207 +1291,15 @@ export default {
1562
1291
  return { ...result, warning: result.warning + ';' + extra };
1563
1292
  return { ...result, warning: extra };
1564
1293
  }
1565
- /**
1566
- * 找到本插件 loader 行所在的补丁文件。返回 0 / 1 / 多份,多份时调用方必须拒绝自动改:
1567
- * 同一条 loader 行出现在两份补丁里会让宿主起不来(重复 id),与 duplicateGuard 同一口径。
1568
- */
1569
- async function loaderTokenFiles() {
1570
- const p = await ensurePaths();
1571
- const files = [];
1572
- const infoOf = new Map();
1573
- for (const abs of [p.projectPatch, p.globalPatch]) {
1574
- let content = '';
1575
- try {
1576
- content = await readPatch(abs);
1577
- }
1578
- catch {
1579
- continue;
1580
- }
1581
- const read = readLoaderToken(content);
1582
- if (read.found) {
1583
- files.push(abs);
1584
- infoOf.set(abs, { token: read.token, disabled: read.disabled });
1585
- }
1586
- }
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 };
1598
- }
1599
- /**
1600
- * 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
1601
- * 界面据此把"重启后生效"说成事实而不是猜测。
1602
- *
1603
- * 局限(写下来免得当成 bug):读不到环境变量,所以靠 `DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN`
1604
- * 提供令牌时(文件里没有 token),这里会报"待重启"。那是保守方向 —— 环境变量改动同样要重启。
1605
- */
1606
- async function tokenConfigState() {
1607
- try {
1608
- const { files, infoOf } = await loaderTokenFiles();
1609
- if (files.length !== 1)
1610
- return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
1611
- const info = infoOf.get(files[0]) || { token: '', disabled: false };
1612
- const configHasToken = info.token !== '';
1613
- // 「文件里配的」与「当前进程在用的」是否一致 —— 比的是**生效**状态(有位子但关了 = 没生效)。
1614
- const fileEffective = configHasToken && !info.disabled;
1615
- return { found: true, configHasToken, configDisabled: info.disabled, pendingRestart: fileEffective !== (TOKEN !== '') };
1616
- }
1617
- catch {
1618
- return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
1619
- }
1620
- }
1621
- /**
1622
- * 改宿主侧的令牌配置:`set` 写入 / 换掉令牌、`off` 关掉令牌功能、`on` 重新打开、
1623
- * `clear` 把令牌从配置里删掉(回到"还没有令牌"那一态)。
1624
- *
1625
- * 关键设计(用户裁定 2026-09-19):
1626
- * - **`off` 不再删除 `token`**,只写一行 `tokenDisabled: true` —— 原令牌保留,随时能开回来,
1627
- * 不需要重新输一遍(此前的实现把配置删了,用户想再开就得重新想一遍令牌)。
1628
- * - **`clear` 才是"删掉"**(用户 2026-09-19 问"令牌没有彻底清除按钮"):删 `config.token`
1629
- * 与 `tokenDisabled` 两行,回到从未设置过的样子 —— 写操作不再要凭证、明文密钥随之
1630
- * 不可见。它是不可逆的那一端(配置里没有副本了),所以与 `off` 共用同一条凭证口径。
1631
- * - **`off` / `clear` 只认"当场再输一次"的令牌**(`value` 对得上才算):请求头里那份已验过
1632
- * 的凭证不算数 —— 解锁之后顺手一点就能把防护关掉 / 把令牌删掉,等于没把守(用户裁定
1633
- * 2026-09-19:「就算输入过了令牌,关闭保护也应该再次输入令牌才能关闭」)。界面据此就地在
1634
- * 「保护开关」/「宿主配置」里展开一个确认框。
1635
- * - **`set` 与 `off` / `clear` 同一条口径**:宿主已有令牌时,改令牌也必须当场再输一次当前
1636
- * 令牌(走 `current` 字段 —— `token` 里是新令牌,证明不了知道旧值;凭证是旧值这件事不能
1637
- * 靠请求头里那份"本次启动已解锁"顶替)。界面据此在「宿主配置」表单里多摆一栏「当前令牌」,
1638
- * 并且不再要求用户先解锁再改。
1639
- * - **`on` 仍接受请求头里的凭证**(`presented`),或输入框里那个值本身就是当前令牌。
1640
- * 两个方向都必须验 —— 否则"能打开 GUI 就能关掉保护",令牌等于白配(要求 9)。
1641
- * - `set`:宿主**还没配**令牌时允许(首次设置 —— 否则这个功能永远打不开),那一态没有
1642
- * "当前令牌"可证明,所以 `current` 不作要求。
1643
- *
1644
- * 不放进 handlers:这样它既不在 HTTP 的普通 op 面上,也**不会**被任何模型工具间接调用 ——
1645
- * 模型不该有能力关掉访问令牌。
1646
- */
1647
- async function tokenConfigure(args, presented) {
1648
- const raw = String((args && args.mode) || '');
1649
- const mode = raw === 'off' ? 'off' : raw === 'on' ? 'on' : raw === 'clear' ? 'clear' : 'set';
1650
- const value = String((args && args.token) || '').trim();
1651
- const currentRaw = String((args && args.current) || '').trim();
1652
- // 关闭 / 重新打开时,用户填进输入框的那个值**就是**当前令牌,可以直接当凭证 ——
1653
- // 省掉"先保存再操作"两步。`set` 不行:那个值是新令牌,不能拿它证明自己知道旧值,
1654
- // 所以它的凭证单独走 `current`。
1655
- const proofByValue = mode !== 'set' && value !== '' && CONFIG_TOKEN !== '' && tokenMatches(value);
1656
- const proofByCurrent = mode === 'set' && currentRaw !== '' && CONFIG_TOKEN !== '' && tokenMatches(currentRaw);
1657
- // 关闭保护 / 删除令牌 / 修改令牌都是"改凭证 / 减防护"的方向:**不认**请求头里已经验过的
1658
- // 那份凭证,必须当面再输一次当前令牌(用户裁定 2026-09-19 —— 先判「关闭」,随后同一口径
1659
- // 推到「修改」,再推到「删除」)。开启保护仍接受请求头里的凭证。
1660
- if (mode === 'off' && CONFIG_TOKEN !== '' && !proofByValue) {
1661
- return { ok: false, code: 'error.secret.badToken', error: '关闭保护要再输一次当前令牌。' };
1662
- }
1663
- if (mode === 'clear' && CONFIG_TOKEN !== '' && !proofByValue) {
1664
- return { ok: false, code: 'error.secret.badToken', error: '删除令牌要再输一次当前令牌。' };
1665
- }
1666
- if (mode === 'set' && CONFIG_TOKEN !== '' && !proofByCurrent) {
1667
- return { ok: false, code: 'error.secret.badToken', error: '修改令牌要先填一次当前令牌。' };
1668
- }
1669
- if (CONFIG_TOKEN !== '' && !presented && !proofByValue && !proofByCurrent) {
1670
- return { ok: false, code: 'error.secret.badToken', error: '要改动访问令牌,请先在「本次启动」里填入当前令牌并解锁。' };
1671
- }
1672
- if ((mode === 'off' || mode === 'clear') && CONFIG_TOKEN === '') {
1673
- return { ok: true, changed: false, note: '宿主侧本来就没有配置令牌。', restartRequired: false };
1674
- }
1675
- if (mode === 'on' && CONFIG_TOKEN === '') {
1676
- return { ok: false, error: '配置里没有令牌:请先用「设置令牌」写入一个,再打开令牌保护。' };
1677
- }
1678
- if (mode === 'set') {
1679
- if (!value)
1680
- return { ok: false, error: '请先填入要设置的令牌' };
1681
- // 换行会写坏 YAML 标量、NUL 会截断路径 —— 这两类直接拒绝比转义更清楚。
1682
- if (/[\r\n\u0000]/.test(value))
1683
- return { ok: false, error: '令牌不能包含换行或控制字符' };
1684
- if (value === CONFIG_TOKEN)
1685
- return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
1686
- }
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。' };
1691
- }
1692
- if (files.length > 1) {
1693
- return { ok: false, error: '本插件的 loader 行同时出现在两份补丁文件里(会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
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
- }
1720
- const abs = files[0];
1721
- return withWriteLock(async () => {
1722
- let content = '';
1723
- try {
1724
- content = await readPatch(abs);
1725
- }
1726
- catch (e) {
1727
- return { ok: false, error: '读取补丁失败: ' + message(e) };
1728
- }
1729
- // 两步改写共用一份内容:先写令牌(set 写值 / clear 删掉),再写开关(set 与 clear 顺带
1730
- // 把"已关闭"那行清掉 —— 换了个新令牌却还留着"已关闭"、或者令牌都没了还写着"已关闭",
1731
- // 都是说不通的)。
1732
- let changed = false;
1733
- // `refused`:loader 行里的 config 形状是插件读不了的(flow style / 重复键)。此时
1734
- // 改写会插入第二个 `config:`,而官方 js-yaml 对重复映射键是 throw —— 后果是 DSH 下次
1735
- // 起不来。所以拒绝并说明,而不是"尽力写一下"。
1736
- if (mode === 'set' || mode === 'clear') {
1737
- const wrote = applyLoaderToken(content, mode === 'clear' ? null : value);
1738
- if (!wrote.found)
1739
- return { ok: false, error: '没找到本插件的 loader 行:' + abs };
1740
- if (wrote.refused)
1741
- return { ok: false, error: wrote.refused };
1742
- content = wrote.content;
1743
- changed = changed || wrote.changed;
1744
- }
1745
- const flag = mode === 'off' ? true : mode === 'clear' ? null : false;
1746
- const toggled = applyLoaderTokenDisabled(content, flag);
1747
- if (!toggled.found)
1748
- return { ok: false, error: '没找到本插件的 loader 行:' + abs };
1749
- if (toggled.refused)
1750
- return { ok: false, error: toggled.refused };
1751
- content = toggled.content;
1752
- changed = changed || toggled.changed;
1753
- if (changed) {
1754
- try {
1755
- await writePatch(abs, content);
1756
- }
1757
- catch (e) {
1758
- return { ok: false, error: '写入补丁失败: ' + message(e) };
1759
- }
1760
- }
1761
- // 改配置**需要重启 DSH 才生效**:当前进程的 TOKEN 是 apply 时读进来的常量。
1762
- // 所以这里如实回 restartRequired,界面据此说清"现在还没生效",而不是让用户以为失败了。
1763
- return { ok: true, changed, mode, restartRequired: changed, path: abs };
1764
- });
1765
- }
1294
+ // ---------- 访问令牌:宿主侧配置的读写(兼容页就地开关) ----------
1295
+ // 正文 2026-10-01 整段搬到 ./token-config.ts(一字未改,只把八个 apply 作用域名字改走
1296
+ // deps)。这一段是 apply 里唯一**改写宿主启动配置**的手:四个方向的凭证判据、写点优先级、
1297
+ // 拒写形状全在那一层的注释里。这里按原位置建一次工厂 —— 它没有状态,两个出口仍然只被
1298
+ // apply 末尾的 HTTP 路由就地调用(刻意不进 handlers 表,模型关不掉访问令牌)。
1299
+ const { tokenConfigState, tokenConfigure } = createTokenConfig({
1300
+ ensurePaths, readPatch, writePatch, withWriteLock, message,
1301
+ TOKEN, CONFIG_TOKEN, tokenMatches,
1302
+ });
1766
1303
  // ---------- 通用侧车 JSON 读写 ----------
1767
1304
  // 为什么留在 index.ts:注入设置(inject-settings.json)也在用这一对,搬进
1768
1305
  // ./mcp/manager.ts 就得为一对 6 行函数再加一层 deps 转发。
@@ -1878,7 +1415,7 @@ export default {
1878
1415
  catch {
1879
1416
  hostNames = [];
1880
1417
  }
1881
- const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
1418
+ const mcp = hostNames.filter((name) => name.startsWith(MCP_TOOL_PREFIX));
1882
1419
  const known = new Set(hostNames);
1883
1420
  const presetId = currentPresetId(agentCtx);
1884
1421
  if (presetId !== null)
@@ -1954,6 +1491,7 @@ export default {
1954
1491
  tools,
1955
1492
  readInjectSettings,
1956
1493
  injectSettingsOp,
1494
+ readSlashSettings,
1957
1495
  toolTableOp,
1958
1496
  toolTableReport: () => toolTableReport(),
1959
1497
  presetRoster: candidates.presetRoster,
@@ -1971,6 +1509,9 @@ export default {
1971
1509
  }),
1972
1510
  // AGENTS.md / 提示词预设域(ops/prompts.ts 提供,见上方 promptOps)。
1973
1511
  ...promptOps,
1512
+ // 快捷提示词域(ops/quick-prompts.ts):quickprompt-list / -create / -update /
1513
+ // -toggle / -remove 与回收站三条。它**不**碰 AGENTS.md、不进场景、不进注入。
1514
+ ...quickPromptOps,
1974
1515
  // 归档会话域(ops/history.ts 提供):history-list / history-sessions /
1975
1516
  // history-export-defaults / dir-list / history-archive / history-archive-batch /
1976
1517
  // history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
@@ -1990,6 +1531,7 @@ export default {
1990
1531
  createSession: agents?.create,
1991
1532
  host: (name) => ctx.get(name),
1992
1533
  promptsDir,
1534
+ quickPromptsDir,
1993
1535
  rulesList: (args) => memoriesService.ops['rules-list'](args),
1994
1536
  skillDetail: (args) => skillsService.ops['skill-detail'](args),
1995
1537
  extractTurnsFromEvents,
@@ -1999,6 +1541,14 @@ export default {
1999
1541
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
2000
1542
  ...buildSceneSyncOps({
2001
1543
  withAgentsMdSync,
1544
+ // 「两轴恒等」(ops/scene-sync.ts 文件头):`rules-set-active` 要能把运行时跟着
1545
+ // 启用集合一起切。`scene-mode-set` 取**包过一层的那份**(锁定守卫 + 目录重算),
1546
+ // 且按调用时取(这里是组装期,联动在 931 行就已装好)。
1547
+ currentModeScene: async () => {
1548
+ const slice = await memoriesService.readArchiveSlice();
1549
+ return slice.mode && slice.mode.scene ? slice.mode.scene : null;
1550
+ },
1551
+ sceneModeSet: (args) => archiveService.ops['scene-mode-set'](args),
2002
1552
  onToolTablePresetChanged: async (scene, value) => {
2003
1553
  // 「修改场景」里改绑工具表方案且改的是**当前启用场景** → 立即把关停名单整体换成
2004
1554
  // 那一份(与「改档案 = 立刻生效」同口径)。解绑('')按下拉里的承诺 =「保持现状」,
@@ -2013,6 +1563,17 @@ export default {
2013
1563
  return;
2014
1564
  await applyToolTableHiddenNow(hidden);
2015
1565
  },
1566
+ // 快捷提示词:改的是**当前启用场景**时按新勾选集立刻应用一次 —— 走的就是「保存档案 =
1567
+ // 立刻生效」那条路(`scene-archive-save` 内含 stale 清理、运行时重应用与快照并入),
1568
+ // 不在这里另写一套应用逻辑。解绑(null)不回滚:这一域回到"保持现状",而进场景前那批
1569
+ // 开关仍在模式快照里,退出照常还原。
1570
+ onQuickPromptsChanged: async (scene, value) => {
1571
+ const slice = await memoriesService.readArchiveSlice();
1572
+ if (!slice.mode || slice.mode.scene !== scene || value === null)
1573
+ return;
1574
+ const cur = slice.archives[scene] || {};
1575
+ await archiveService.ops['scene-archive-save']({ scene, archive: { ...cur, quickPrompts: value } });
1576
+ },
2016
1577
  rulesOps: {
2017
1578
  'rules-set-active': (args) => memoriesService.ops['rules-set-active'](args),
2018
1579
  'rules-update-scene': (args) => memoriesService.ops['rules-update-scene'](args),
@@ -2038,6 +1599,9 @@ export default {
2038
1599
  // 场景页的界面设置(读 / 写):进入场景前要不要弹预览卡。按写操作门禁(它写侧车),
2039
1600
  // 但**不进场景冻结** —— 它是界面提示,与五个管理域的只读无关(见 scene-settings.ts)。
2040
1601
  'scene-settings': (args) => sceneSettingsOp(args),
1602
+ // 斜杠命令入口的开关(读 / 写):客户端 boot 时读一次决定挂不挂 `/` 菜单里的「工具」段,
1603
+ // 兼容页那两颗开关写一次。同 `scene-settings`:按写门禁但不冻结(见 slash-settings.ts)。
1604
+ 'slash-settings': (args) => slashSettingsOp(args),
2041
1605
  // state-doctor:跨域**悬空引用**体检(只读,实现与理由见 src/ops/state-doctor.ts)。
2042
1606
  // 这里只做"把权威集合取来"这一件事 —— 某一域读失败就传 null,体检会把它记进
2043
1607
  // `skipped` 而不是当成"该域没有悬空项"(读不到 ≠ 不存在,报成后者就是骗人)。
@@ -2383,6 +1947,18 @@ export default {
2383
1947
  next.subagents = toggleInList(Array.isArray(next.subagents) ? next.subagents.slice() : [], name, enabled);
2384
1948
  break;
2385
1949
  }
1950
+ case 'quickprompt-toggle': {
1951
+ // 与上面几段**不同**:这一段未定义说的是"这一域保持现状",不是"一个都没勾"。
1952
+ // 所以在没绑定的场景里手动勾一条快捷词,不该悄悄把它变成"按场景勾选"——
1953
+ // 那会让下一次进场景的行为整个换掉(用户看到的只是"我开了一个开关")。
1954
+ if (!Array.isArray(next.quickPrompts))
1955
+ return null;
1956
+ const id = String((args && args.id) || '');
1957
+ if (!id)
1958
+ return null;
1959
+ next.quickPrompts = toggleInList(next.quickPrompts.slice(), id, enabled);
1960
+ break;
1961
+ }
2386
1962
  default: return null;
2387
1963
  }
2388
1964
  }
@@ -2429,6 +2005,7 @@ export default {
2429
2005
  ensurePaths,
2430
2006
  pluginVersion: () => PKG_VERSION,
2431
2007
  promptsDir: () => promptsDir,
2008
+ quickPromptsDir: () => quickPromptsDir,
2432
2009
  message,
2433
2010
  });
2434
2011
  Object.assign(handlers, snapshotOps);
@@ -2519,18 +2096,23 @@ export default {
2519
2096
  * 调用方就是猜。这里给工具看到的表包一层,界面上 handlers 表那份原样不动,两条路各记各的。
2520
2097
  * 只多记一条流水:不改行为、不改返回值、也不吞异常(写类与成败的判据在 ./audit-log.ts)。
2521
2098
  */
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
- };
2099
+ /**
2100
+ * **模型侧的冻结闸**(审查 F3,0.17.0):与 HTTP handlers 用同一个判据函数。
2101
+ *
2102
+ * 此前冻结只装在 `handlers` 上,而模型工具拿的是 `service.ops` 的副本 —— 于是「锁一个
2103
+ * **非活动**场景」在 HTTP 上被拒、在模型侧五个域(技能/记忆/MCP/子智能体/场景档案)
2104
+ * 全放行:`lockedSceneGuard()` 只看**当前**场景,锁的是别的场景时它返回 null。
2105
+ * 一个界面禁用、模型可写的门禁,等于没有门禁。
2106
+ *
2107
+ * 装在这里而不是 service.ops 上:进/退场景的运行时应用直调 `service.ops`
2108
+ * (`applySkills` / `applyMcpServerSwitches` / …),装在那一层会把场景自己锁死(见
2109
+ * `createFrozenGate` 的注释)。`auditOps` 返回的是**新表**,原表不受影响。
2110
+ */
2111
+ const frozenGate = createFrozenGate({ lockedSceneNames, activeSceneName });
2112
+ // 被拒时不记流水:什么都没改,记下来只会把真变更淹没(与只读 op 同一口径)。
2113
+ const modelAudit = (opName, args, result) => recordOpAudit(opName, args, 'model', result);
2114
+ const auditOps = (ops) => guardModelOps(ops, frozenGate, modelAudit);
2115
+ const auditFn = (opName, fn) => guardModelOp(opName, fn, frozenGate, modelAudit);
2534
2116
  const toolDeps = {
2535
2117
  defineTool,
2536
2118
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
@@ -2564,7 +2146,16 @@ export default {
2564
2146
  buildSkillTools({ ...toolDeps, skillsOps: auditOps(skillsService.ops) });
2565
2147
  // 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
2566
2148
  // 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
2567
- buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded, promptsDir });
2149
+ //
2150
+ // `applyPresetGuarded` 是模型侧**唯一**不经 op 表的写路径(`prompt_manager_apply` 直接调它,
2151
+ // 见上面 `frozenGate` 的注释),所以冻结闸要在这里显式套一层 —— 它对应的 HTTP op 是
2152
+ // `agentsmd-apply`(冻结清单里那条)。少了这一层,"锁着**别的**场景"时模型仍能换掉当前
2153
+ // 场景的提示词绑定(审查 F3 的另一半)。
2154
+ const applyPresetForModel = async (id) => {
2155
+ const denied = await frozenGate('agentsmd-apply', { id });
2156
+ return denied ? { ok: false, error: denied } : applyPresetGuarded(id);
2157
+ };
2158
+ buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded: applyPresetForModel, promptsDir });
2568
2159
  buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2569
2160
  // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2570
2161
  // + 进入与退出(`_switch`)。
@@ -2839,214 +2430,15 @@ export default {
2839
2430
  mcp.dispose();
2840
2431
  }, 'dsh-plugin-tool-management: tool restriction cleanup');
2841
2432
  // ---------- HTTP API route (UI half), registered defensively ----------
2842
- if (webServer) {
2843
- // Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
2844
- // payloads (64 MiB of raw content). Reachability is decided by
2845
- // fenceRejection() below — the host's Host/Origin + browser-session fence
2846
- // (or a correct explicit token). `config.maxBodyBytes` overrides the cap
2847
- // (tests use a small value).
2848
- const configuredMaxBody = Number(config?.maxBodyBytes);
2849
- // 默认 88 MiB:够塞下一份带多张大附件(图片 / PDF)的技能或记忆打包体,又不至于让
2850
- // 一个失控的客户端把进程内存吃光。`config.maxBodyBytes` 可覆盖(测试用小值)。
2851
- const DEFAULT_MAX_BODY_BYTES = 88 * 1024 * 1024;
2852
- const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : DEFAULT_MAX_BODY_BYTES;
2853
- const readBody = (req) => new Promise((resolve, reject) => {
2854
- // 先看 content-length:注定超限的请求在**读第一个字节之前**就拒掉。以前只能边收边数,
2855
- // 一个 5 GB 的上传会先让我们缓冲满 88 MiB 才回错。读取流照旧 drain(一个字节都不留),
2856
- // 否则响应写完后服务端会直接拆连接,客户端可能在上传中途拿到网络错误而不是下面这句 JSON。
2857
- // 错误消息必须含 `body too large` —— 后面的 catch 靠这个子串分流。
2858
- const declared = Number(req.headers && req.headers['content-length']);
2859
- if (Number.isFinite(declared) && declared > MAX_BODY) {
2860
- const drain = req.resume;
2861
- if (typeof drain === 'function')
2862
- drain.call(req);
2863
- reject(new Error('request body too large'));
2864
- return;
2865
- }
2866
- // Accumulate Buffers and decode ONCE at the end: decoding each chunk
2867
- // separately corrupts multi-byte UTF-8 characters that straddle a
2868
- // chunk boundary (e.g. long Chinese skill bodies).
2869
- const chunks = [];
2870
- let size = 0;
2871
- req.on('data', (c) => {
2872
- const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
2873
- size += buf.length;
2874
- if (size > MAX_BODY) {
2875
- reject(new Error('request body too large'));
2876
- return;
2877
- }
2878
- chunks.push(buf);
2879
- });
2880
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2881
- req.on('error', reject);
2882
- });
2883
- // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
2884
- // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
2885
- // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
2886
- // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
2887
- // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
2888
- // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
2889
- const connection = (() => {
2890
- try {
2891
- return ctx.get('connection');
2892
- }
2893
- catch {
2894
- return undefined;
2895
- }
2896
- })();
2897
- try {
2898
- // ctx.effect wires the route's disposer into this plugin's scope, so an
2899
- // unload (HMR removal, disable, update) unregisters the route — the
2900
- // documented cleanup contract (webServer.register does not auto-scope).
2901
- ctx.effect(() => webServer.register({
2902
- kind: 'exact',
2903
- path: '/dsh-plugin-tool-management/api',
2904
- handler: async (req, res) => {
2905
- // 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
2906
- // (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
2907
- // LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权。
2908
- //
2909
- // 「未配置令牌时」别读成"仍有门禁":下面的写 op 判据是
2910
- // `if (TOKEN && WRITE_OPS.has(op))` —— `TOKEN === ''` 时整条短路,79 个写 op
2911
- // 只剩 `fenceRejection` 一道。而它自身有两种形态:宿主栅栏在场时含 cookie 鉴权;
2912
- // 栅栏缺席或抛错时退回本地判定,**只查回环 Host 与(若给了)Origin 自比对**,
2913
- // 没有 cookie 那一层 —— 本机任意进程都能调写 op。所以准确说法是:
2914
- // 未配置令牌时不再有「**无栅栏**放行」的缺口,但进程级边界只有配了令牌才有。
2915
- const hdr = (name) => {
2916
- const v = req.headers?.[name];
2917
- return Array.isArray(v) ? v[0] ?? '' : v ?? '';
2918
- };
2919
- if (String(req.method || 'POST').toUpperCase() !== 'POST') {
2920
- res.writeHead(405, { 'content-type': 'application/json' });
2921
- res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
2922
- return;
2923
- }
2924
- if (hdr('x-dsh-plugin') !== 'dsh-plugin-tool-management') {
2925
- res.writeHead(403, { 'content-type': 'application/json' });
2926
- res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
2927
- return;
2928
- }
2929
- // 「本次带的这串对不对」比的是**存量**令牌:关掉令牌功能之后仍然要凭它才能开回来、
2930
- // 换掉、或看明文(要求 9)。
2931
- const tokenAccepted = CONFIG_TOKEN !== '' && tokenMatches(hdr('x-dsh-token'));
2932
- // 验过就置闩(本次进程一次即可)。`agent/pre-step` 的令牌门禁读的就是它 ——
2933
- // 那一边看不到请求头,只能由这里告诉它"这台机器已经有人验过令牌了"。
2934
- if (tokenAccepted)
2935
- markAccepted();
2936
- if (!tokenAccepted) {
2937
- const fence = fenceRejection(req, connection);
2938
- if (fence) {
2939
- res.writeHead(fence.status, { 'content-type': 'application/json' });
2940
- res.end(JSON.stringify({ ok: false, error: fence.error }));
2941
- return;
2942
- }
2943
- }
2944
- res.writeHead(200, { 'content-type': 'application/json' });
2945
- try {
2946
- let payload = {};
2947
- try {
2948
- payload = JSON.parse((await readBody(req)) || '{}');
2949
- }
2950
- catch (e) {
2951
- if (String(e?.message).includes('body too large')) {
2952
- res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
2953
- return;
2954
- }
2955
- /* otherwise fall through with {} */
2956
- }
2957
- const op = String(payload.op || '');
2958
- // 敏感 op(会吐明文密钥与完整配置):**配了令牌就必须带对的**,没配令牌则不设防
2959
- // (判定与理由见 http-fence.ts 的 secretOpRejection —— 用户裁定 2026-09-19:
2960
- // 没配令牌时那道门没有出路,用户无从填一个不存在的令牌)。
2961
- // 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
2962
- // 打开 GUI 的浏览器都拿得到全部密钥原文。
2963
- if (SENSITIVE_OPS.has(op)) {
2964
- // 明文门禁看的是**存量**令牌(CONFIG_TOKEN 而不是生效值 TOKEN):关掉令牌
2965
- // 功能之后仍然"要凭存量令牌才给明文"—— 那条路是通的(填对就能看),
2966
- // 与"从来没配过"不同。
2967
- const gate = secretOpRejection({ tokenConfigured: CONFIG_TOKEN !== '', tokenAccepted });
2968
- if (gate) {
2969
- res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
2970
- return;
2971
- }
2972
- }
2973
- // 「读改写」两态 op:不带 `set:true` 时是**纯读**,与写门禁无关。
2974
- // `inject-settings`(兼容页的「注入」块)、`tool-table`(同页的「模型工具表」)、
2975
- // `scene-settings`(场景页的提醒开关)与 `mcpm-settings`(MCP 页的轮询设置)
2976
- // 都用同一个 op 承担读与写,于是整条 op 被列进 WRITE_OPS —— 读侧也一起被拦,
2977
- // 界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19 用户报的)。
2978
- // 判据是**这次调用要不要写**,不是 op 名在不在名单里。
2979
- const readOnlyCall = (op === 'inject-settings' || op === 'tool-table' || op === 'scene-settings' || op === 'mcpm-settings')
2980
- && !(payload.args && payload.args.set === true);
2981
- if (TOKEN && WRITE_OPS.has(op) && !readOnlyCall && !tokenMatches(hdr('x-dsh-token'))) {
2982
- // 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
2983
- // 「去填令牌」跳转按钮。这条错误会出现在**任意**页面,而入口只有一个。
2984
- res.end(JSON.stringify({ ok: false, code: TOKEN_CODE_BAD, error: TOKEN_MSG }));
2985
- return;
2986
- }
2987
- // 令牌状态(只读,**不需要**令牌本身):回答"宿主配没配"、"本次带的这串对不对"、
2988
- // "配置文件里的改动是不是还没重启生效"、"本次进程的标识"。就地回答而不是进 handlers 表:
2989
- // 前者的答案来自本次请求头与进程状态,而后者的 handlers 只拿得到 args。
2990
- // 不返回令牌、也不返回它的任何片段 —— 泄给同源页面等于把最后一道门交出去。
2991
- if (op === 'token-status') {
2992
- const state = await tokenConfigState();
2993
- res.end(JSON.stringify({
2994
- ok: true,
2995
- // 「宿主配置了令牌」= 配置里**有**值(关掉也算有:随时能开回来)。
2996
- hostConfigured: CONFIG_TOKEN !== '',
2997
- // 当前进程里令牌功能是开还是关(关掉 = 写操作不要求令牌)。
2998
- disabled: TOKEN_DISABLED,
2999
- // 「当前进程里令牌**在生效**」= 写操作要不要令牌。关掉或没配都是 false。
3000
- // 界面据此判断"填令牌"这件事有没有意义(没生效时填了也没人验,还会把
3001
- // 一句无处可填的提示挂在屏幕上 —— 用户 2026-09-19 报的就是这个)。
3002
- active: TOKEN !== '',
3003
- accepted: tokenAccepted,
3004
- bootId: BOOT_ID,
3005
- configHasToken: state.configHasToken,
3006
- configDisabled: state.configDisabled,
3007
- configFound: state.found,
3008
- pendingRestart: state.pendingRestart,
3009
- }));
3010
- return;
3011
- }
3012
- // 设置 / 关闭令牌:同样就地处理(理由见 tokenConfigure 的注释 —— 也正因为不进
3013
- // handlers,模型侧没有任何工具能间接关掉它)。
3014
- if (op === 'token-configure') {
3015
- res.end(JSON.stringify(withPatchWarnings(await tokenConfigure(payload.args || {}, tokenAccepted))));
3016
- return;
3017
- }
3018
- // 「清除令牌」的配套:把「本次启动已验过」的闩重新挂上。没有它,解锁一次之后
3019
- // 清除令牌 + 强刷新,浏览器里已无令牌,闩却挂着 —— pre-step 门禁读闩
3020
- // (tokenGateActive),整个启动期都放行对话(2026-09-19 用户实测的漏洞)。
3021
- // 不要求凭证:它只会收紧(要求重新验令牌),给不了任何人任何权限;同样不进
3022
- // handlers —— 模型不该有能力拨这颗闩。
3023
- if (op === 'token-unaccept') {
3024
- unmarkAccepted();
3025
- res.end(JSON.stringify({ ok: true }));
3026
- return;
3027
- }
3028
- const fn = handlers[op];
3029
- if (!fn) {
3030
- res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
3031
- return;
3032
- }
3033
- const result = await fn(payload.args || {});
3034
- // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
3035
- // `readOnlyCall` 是上面那四个「读改写两态 op」的纯读分支 —— 它们没改任何状态,不记。
3036
- if (!readOnlyCall)
3037
- recordOpAudit(op, payload.args || {}, 'panel', result);
3038
- res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
3039
- }
3040
- catch (e) {
3041
- res.end(JSON.stringify({ ok: false, error: message(e) }));
3042
- }
3043
- },
3044
- }), 'dsh-plugin-tool-management: api route');
3045
- }
3046
- catch (e) {
3047
- // A registration failure must never take down the whole entry: log and continue.
3048
- console.error('[dsh-plugin-tool-management] webServer route registration failed:', message(e));
3049
- }
3050
- }
2433
+ // 正文 2026-10-01 整段搬到 ./http-api.ts(一字未改,只把 apply 作用域的名字改走 deps)。
2434
+ // 位置也保持原样:这一段是 apply 的最后一条注册,前面的 handlers 表 / 门禁 / 白名单都已就位。
2435
+ // 传引用而不是快照:`handlers` 在本调用之前还被 Object.assign 追加过、被 installHandlerGuards
2436
+ // 逐条重包过,注册之后请求期读的也是当时的最新那份。
2437
+ installHttpApi({
2438
+ ctx, config, webServer, handlers, message, withPatchWarnings,
2439
+ tokenConfigState, tokenConfigure,
2440
+ CONFIG_TOKEN, TOKEN_DISABLED, TOKEN, tokenMatches, BOOT_ID, markAccepted, unmarkAccepted,
2441
+ WRITE_OPS, SENSITIVE_OPS,
2442
+ });
3051
2443
  },
3052
2444
  };