dsh-plugin-tool-management 0.17.0 → 0.18.5

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 (84) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +19 -4
  3. package/README_EN.md +19 -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 +29 -0
  21. package/lib/client.js +1662 -161
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/exports/transcript.js +223 -0
  33. package/lib/http-api.js +229 -0
  34. package/lib/hub.js +2 -0
  35. package/lib/index.js +138 -868
  36. package/lib/mcp/inspect-probe.js +199 -0
  37. package/lib/mcp/manager.js +30 -548
  38. package/lib/mcp/tool-visibility.js +388 -0
  39. package/lib/memories/archive-engine.js +142 -5
  40. package/lib/memories/archive.js +10 -1
  41. package/lib/memories/discovery.js +261 -0
  42. package/lib/memories/export-plan.js +42 -0
  43. package/lib/memories/memories-types.js +1 -0
  44. package/lib/memories/service.js +4 -38
  45. package/lib/memories/snapshot.js +10 -252
  46. package/lib/op-registry.js +21 -0
  47. package/lib/ops/compat.js +11 -0
  48. package/lib/ops/quick-prompts.js +26 -0
  49. package/lib/ops/scene-records.js +40 -0
  50. package/lib/ops/scene-sync.js +71 -1
  51. package/lib/ops/sessions.js +34 -14
  52. package/lib/ops/snapshot.js +18 -12
  53. package/lib/prompts/quick-service.js +211 -0
  54. package/lib/scene-linkage.js +184 -0
  55. package/lib/sessions/archive-schemas.js +121 -0
  56. package/lib/sessions/path-key.js +46 -0
  57. package/lib/sessions/workspace.js +29 -166
  58. package/lib/settings-sidecars.js +143 -0
  59. package/lib/skills/core-types.js +8 -0
  60. package/lib/skills/core-util.js +81 -0
  61. package/lib/skills/core.js +56 -2326
  62. package/lib/skills/dir-watch.js +71 -0
  63. package/lib/skills/frontmatter.js +96 -0
  64. package/lib/skills/homes.js +14 -0
  65. package/lib/skills/import-pipeline.js +231 -0
  66. package/lib/skills/manager-state.js +800 -0
  67. package/lib/skills/official.js +269 -0
  68. package/lib/skills/provider-registration.js +89 -0
  69. package/lib/skills/service.js +5 -240
  70. package/lib/skills/skill-roots.js +287 -0
  71. package/lib/skills/skill-trash.js +265 -0
  72. package/lib/skills/system-trash.js +97 -0
  73. package/lib/skills/upload-pipeline.js +374 -0
  74. package/lib/slash-settings.js +25 -0
  75. package/lib/subagents/persona-parse.js +312 -0
  76. package/lib/subagents/persona-types.js +5 -0
  77. package/lib/subagents/persona-write.js +93 -0
  78. package/lib/subagents/run-provider.js +159 -0
  79. package/lib/subagents/service.js +14 -638
  80. package/lib/subagents/tool-filter.js +98 -0
  81. package/lib/token-config.js +240 -0
  82. package/lib/tool-table-switch.js +163 -0
  83. package/lib/tools/scene.js +40 -9
  84. package/package.json +3 -2
package/lib/index.js CHANGED
@@ -18,6 +18,7 @@ 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
23
  import { DEFAULT_PROFILE_NAME, MCP_TOOL_PREFIX, PROFILE_CANDIDATES, serverNameCandidates, splitMcpToolName } from './host-names.js';
23
24
  import { createMemoriesService } from './memories/service.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,15 +50,16 @@ 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, scanLoaderRows } 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
60
  import { createAccessToken, createFrozenGate, createOpWhitelist, createSceneLock, guardModelOp, guardModelOps, installHandlerGuards } from './request-gate.js';
55
- import { auditOpRegistry, auditProblems, isReadCall } from './op-registry.js';
61
+ // `isReadCall` 随 HTTP 路由一起搬走了(写门禁与审计流水都读同一份登记表判据)。
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';
58
65
  // model 工具(ctx.tools.register)按域分文件;共享的依赖形状见 tools/deps.ts。
@@ -63,41 +70,13 @@ import { buildMemoryTools } from './tools/memory.js';
63
70
  import { buildSceneTools } from './tools/scene.js';
64
71
  import { buildSubagentTools } from './tools/subagent.js';
65
72
  import { createScenePromptSync } from './scene-prompt-sync.js';
66
- import { extractText } from './imports/parsers.js';
73
+ import { extractExportTurns, serializeTranscript } from './exports/transcript.js';
67
74
  import { spawn } from 'node:child_process';
68
75
  import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
69
76
  import { basename, dirname, join } from 'node:path';
70
77
  // ---------------------------------------------------------------------------
71
- // Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
72
- // so an exported file can be imported back losslessly. Pure Node, no ctx.
78
+ // Transcript export lives in src/exports/transcript.ts (0.18.5).
73
79
  // ---------------------------------------------------------------------------
74
- /** 从会话事件数组提取用户/助手纯文本轮次(与导入解析器对称)。 */
75
- function extractTurnsFromEvents(events) {
76
- const turns = [];
77
- for (const ev of events || []) {
78
- if (!ev || typeof ev !== 'object')
79
- continue;
80
- const e = ev;
81
- let content;
82
- if (e.type === 'user/message')
83
- content = e.data && e.data.content;
84
- else if (e.type === 'assistant/message')
85
- content = e.data && e.data.message && e.data.message.content;
86
- else
87
- continue;
88
- const text = extractText(content).trim();
89
- if (text)
90
- turns.push({ role: e.type === 'user/message' ? 'user' : 'assistant', text });
91
- }
92
- return turns;
93
- }
94
- /** 序列化为可再导入的转录文本:Codex 风格 Markdown 或 Claude Code 风格 JSONL。 */
95
- function serializeTurns(turns, format) {
96
- if (format === 'jsonl') {
97
- return turns.map((t) => JSON.stringify({ type: t.role, message: { role: t.role, content: t.text } })).join('\n');
98
- }
99
- return turns.map((t) => (t.role === 'user' ? '## User\n' : '### Assistant\n') + t.text).join('\n\n');
100
- }
101
80
  /**
102
81
  * `inject` 的服务名清单(单一来源):既声明给宿主,也是「挂载心跳」与 doctor 核对的内容。
103
82
  *
@@ -205,6 +184,13 @@ export default {
205
184
  return p.home + sep + 'AGENTS.md';
206
185
  },
207
186
  });
187
+ // ---------- 快捷提示词(hub/quick-prompts/)----------
188
+ // 与上面的预设库**两份存储**,不是同一份加个标记位:`list()` 只扫自己目录,所以快捷词
189
+ // 天然进不了「应用 / 场景绑定 / 注入 / 模型工具」这四条只认 hub/prompts/ 的路径。
190
+ // 理由见 ./prompts/quick-service.ts 头注。
191
+ const quickPromptsDir = hubPath('quick-prompts');
192
+ const quickPromptsService = createQuickPromptsService({ dir: quickPromptsDir });
193
+ const quickPromptOps = buildQuickPromptOps(quickPromptsService);
208
194
  // ---------- rules(规则/记忆,v0.4)----------
209
195
  // 记忆真源 $DSH_HOME/tool-management/memories/<场景>/<name>.md(仅用户级,D1)。
210
196
  // **场景是显式记录**(memories-index.json 的 scenes 切片):含保留场景 `global`(界面「全局」,
@@ -552,6 +538,26 @@ export default {
552
538
  await subagentService.enabledStore.setEnabled(on, true);
553
539
  void subagentCatalog.refresh();
554
540
  },
541
+ // 快捷提示词(0.18.0):档案定义了 quickPrompts 段时,进入场景 = 把每一条的开关置成
542
+ // 「在不在这份清单里」,退出按快照还原。读写的就是提示词页那颗开关的同一份存储
543
+ // (`hub/quick-prompts/<id>/meta.json`),不在别处再算一套状态。
544
+ knownQuickPrompts: async () => {
545
+ const r = await quickPromptsService.list();
546
+ return new Set(r.ok === true ? r.prompts.map((p) => p.id) : []);
547
+ },
548
+ quickPromptStates: async () => {
549
+ const r = await quickPromptsService.list();
550
+ if (r.ok !== true)
551
+ throw new Error(r.error);
552
+ return Object.fromEntries(r.prompts.map((p) => [p.id, p.enabled !== false]));
553
+ },
554
+ applyQuickPromptSwitches: async (switches) => {
555
+ // 已不存在的 id 由 setEnabled 自己回 ok:false —— 这里**不**把它抛出去:退出场景时
556
+ // "还原一条已被删掉的快捷词"没有可写的地方,让它报错会把整次退出卡住
557
+ // (与 mcpServerRowsToRestore「现在已不存在的行直接跳过」同一口径)。
558
+ for (const s of switches)
559
+ await quickPromptsService.setEnabled(s.id, s.enabled);
560
+ },
555
561
  // 记忆 id 全集。引擎已不消费(memories 段 P5 起废弃),实现保留以维持接口形状。
556
562
  knownMemoryIds: async () => {
557
563
  const r = await memoriesService.ops['rules-list']({});
@@ -572,156 +578,20 @@ export default {
572
578
  .filter((s) => s.active)
573
579
  .map((s) => slice.archives[s.name]?.subagents ?? []);
574
580
  };
575
- // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
576
- // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
577
- // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
578
- // (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
579
- // 选择时铺(见下面的 `toolTableSettingsFrom`)。
580
- //
581
- // 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
582
- // 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
583
- const TOOL_TABLE_FILE = 'tool-table.json';
584
- const TOOL_TABLE_TTL_MS = 3000;
585
- /** 注册时量到的工具体积(只记真进了表的那些;量的是 register 收到的整份定义)。 */
586
- const toolTableSizes = new Map();
587
- function recordToolSize(def) {
588
- try {
589
- const name = String(def.name ?? '');
590
- if (name === '')
591
- return;
592
- // 与宿主同一口径(dsh-token-meter 的 estimateToolsTokens 就是把 tools 整份
593
- // JSON.stringify 后除以 4),所以这里量整份定义,不只量 description。
594
- toolTableSizes.set(name, JSON.stringify(def).length);
595
- }
596
- catch { /* 量不出来就不记:它只影响界面上的估算数字 */ }
597
- }
598
- let toolTableCache = null;
599
- async function readToolTableSettings(force = false) {
600
- if (toolTableCache && !force && Date.now() - toolTableCache.at < TOOL_TABLE_TTL_MS)
601
- return toolTableCache.value;
602
- await ensurePaths();
603
- const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
604
- // 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
605
- // 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
606
- // 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
607
- // 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
608
- // 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
609
- // 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
610
- const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
611
- const value = migrated.settings;
612
- if (migrated.changed) {
613
- try {
614
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
615
- }
616
- catch { /* 回写失败:本次仍按迁移后的值生效 */ }
617
- }
618
- const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
619
- toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
620
- // 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
621
- // 自己重排,它们只是"下次问的时候读到新值";把限制摘掉/装上这一步必须有人做。
622
- // (`mcp` 在 apply 里是同步赋值、本函数只可能在自己的 await 之后回到这里,所以到得了。)
623
- if (changed) {
624
- try {
625
- mcp.scheduleToolRestrictions();
626
- }
627
- catch { /* 兜底:拿不到 manager 就等下一次 tools/change */ }
628
- }
629
- return value;
630
- }
631
- /**
632
- * 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
633
- * **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
634
- * 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
635
- * 工具凭空消失)。
636
- */
637
- function toolTableHidden() {
638
- if (toolTableCache === null) {
639
- void readToolTableSettings().catch(() => { });
640
- return EMPTY_TOOL_SET;
641
- }
642
- return toolTableCache.off;
643
- }
644
- const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
645
- const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
646
- /** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
647
- async function writeToolTableSettings(next) {
648
- await ensurePaths();
649
- // 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
650
- // 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
651
- return withWriteLock(async () => {
652
- try {
653
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
654
- }
655
- catch (e) {
656
- return { ok: false, error: '设置保存失败: ' + message(e) };
657
- }
658
- toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
659
- return { ok: true };
660
- });
661
- }
662
- /**
663
- * 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
664
- *
665
- * `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
666
- * `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
667
- * 不给删除键,词典出它的名字。
668
- */
669
- const toolTablePayload = (value) => ({
670
- ok: true, hidden: value.hidden, presets: value.presets,
671
- defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
581
+ // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)→ ./tool-table-switch.ts ----------
582
+ // 那 134 行整段搬进同目录模块(侧车 TTL 缓存、三条写路径、`tool-table` op 正文),闭包名字
583
+ // 走 deps。工具体积表 toolTableSizes 按**同一个 Map 对象**交回来:注册口(defineTool 那层)
584
+ // 在外面写它、op 与场景写路径读它 —— 交回对象而不是复制,所以母文件这几处一个字都不用改。
585
+ // 这段仍然留在两个目录之前:目录里那句「用 `X` 查」的提示要读 toolTableHidden —— 搬走的是
586
+ // 这段的正文,不是这段的顺序(顺序就是依赖顺序)。
587
+ // mcp / skillCatalog / subagentCatalog 在本块之后才建,按 getter 交进去:取值发生在真正要
588
+ // 用的那一刻,与原闭包同形,既不撞 TDZ 也不动搬走的那些行。
589
+ const { toolTableSizes, recordToolSize, readToolTableSettings, toolTableHidden, writeToolTableSettings, toolTableReport, toolTableOp, } = createToolTableSwitch({
590
+ ensurePaths, readJsonFile, writeJsonFile, withWriteLock, message, EMPTY_TOOL_SET,
591
+ get mcp() { return mcp; },
592
+ get skillCatalog() { return skillCatalog; },
593
+ get subagentCatalog() { return subagentCatalog; },
672
594
  });
673
- /** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
674
- const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
675
- async function toolTableOp(args) {
676
- const current = await readToolTableSettings();
677
- if (!args || args.set !== true) {
678
- // 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
679
- // 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
680
- if (args && args.presetSave !== undefined) {
681
- const name = presetNameOf(args.presetSave);
682
- if (name === '')
683
- return { ok: false, code: 'nameEmpty' };
684
- const added = addPreset(current, name, current.hidden);
685
- // 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
686
- if (added.exists)
687
- return { ok: false, code: 'nameTaken', name };
688
- if (current.presets.length >= PRESET_MAX_COUNT) {
689
- return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
690
- }
691
- const saved = await writeToolTableSettings(added.settings);
692
- if (!saved.ok)
693
- return saved;
694
- return toolTablePayload(added.settings);
695
- }
696
- if (args && args.presetDelete !== undefined) {
697
- const name = presetNameOf(args.presetDelete);
698
- const dropped = dropPreset(current, name);
699
- if (!dropped.changed)
700
- return { ok: false, code: 'notFound', name };
701
- const saved = await writeToolTableSettings(dropped.settings);
702
- if (!saved.ok)
703
- return saved;
704
- return toolTablePayload(dropped.settings);
705
- }
706
- return toolTablePayload(current);
707
- }
708
- // 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
709
- // ("names unknown global tool")。注册失败的工具本来也不在表里。
710
- const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
711
- .filter((name) => toolTableSizes.has(name));
712
- const next = { hidden, presets: current.presets };
713
- const saved = await writeToolTableSettings(next);
714
- if (!saved.ok)
715
- return saved;
716
- // 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
717
- // 目录的 refresh 会把新的一句话注入出去 —— 内容变了才重发,这是正确行为。
718
- mcp.scheduleToolRestrictions();
719
- await Promise.all([
720
- skillCatalog.refresh().catch(() => { }),
721
- subagentCatalog.refresh().catch(() => { }),
722
- ]);
723
- return toolTablePayload(next);
724
- }
725
595
  // ---------- 「有官方等价物 ⇒ 我们让位」----------
726
596
  // 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
727
597
  // 件事:按名字给正文。标准类预设下它在场,第二份就是白付的 174 tok/轮。
@@ -779,90 +649,16 @@ export default {
779
649
  state: () => skillsService.ops['skill-state']({}),
780
650
  listToolVisible: () => !toolTableHidden().has('skill_manager_list'),
781
651
  });
782
- // 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
783
- // - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
784
- // 下是否仍然注入。默认 false = 跟随预设。
785
- // - domains:各域开关(任何预设下都生效;界面五个勾选)。
786
- // 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
787
- const INJECT_SETTINGS_FILE = 'inject-settings.json';
788
- const INJECT_SETTINGS_TTL_MS = 3000;
789
- let injectSettingsCache = null;
790
- async function readInjectSettings(force = false) {
791
- if (injectSettingsCache && !force && Date.now() - injectSettingsCache.at < INJECT_SETTINGS_TTL_MS)
792
- return injectSettingsCache.value;
793
- await ensurePaths();
794
- const raw = await readJsonFile(hubPath(INJECT_SETTINGS_FILE));
795
- injectSettingsCache = { at: Date.now(), value: normalizeInjectSettings(raw) };
796
- return injectSettingsCache.value;
797
- }
798
- /** 同步快照(注入器取用);还没加载时先返回默认值,并顺手预热一次。 */
799
- function injectSettingsSync() {
800
- if (injectSettingsCache)
801
- return injectSettingsCache.value;
802
- void readInjectSettings().catch(() => { });
803
- return DEFAULT_INJECT_SETTINGS;
804
- }
805
- /** 边界提示要的注入设置快照(总开关 + 各域开关)。 */
806
- const injectNoticeOptions = () => {
807
- const s = injectSettingsSync();
808
- return { underSuppressingPresets: s.underSuppressingPresets, domains: s.domains };
809
- };
810
- async function injectSettingsOp(args) {
811
- const current = await readInjectSettings();
812
- if (!args || args.set !== true)
813
- return { ok: true, settings: current };
814
- const next = normalizeInjectSettings({
815
- underSuppressingPresets: typeof args.underSuppressingPresets === 'boolean'
816
- ? args.underSuppressingPresets
817
- : current.underSuppressingPresets,
818
- domains: { ...current.domains, ...(args.domains && typeof args.domains === 'object' ? args.domains : {}) },
819
- });
820
- await ensurePaths();
821
- return withWriteLock(async () => {
822
- try {
823
- await writeJsonFile(hubPath(INJECT_SETTINGS_FILE), next);
824
- }
825
- catch (e) {
826
- return { ok: false, error: '设置保存失败: ' + message(e) };
827
- }
828
- injectSettingsCache = { at: Date.now(), value: next };
829
- return { ok: true, settings: next };
830
- });
831
- }
832
- // 场景页的界面设置(侧车 `scene-settings.json`):目前只有一项 —— 进入场景前要不要先弹
833
- // 那张「会改什么」的卡。它纯粹是界面提示,所以**不进场景冻结**(锁着场景的人在场景页
834
- // 照样能关掉提醒,那与五个管理域的只读无关)。
835
- const SCENE_SETTINGS_FILE = 'scene-settings.json';
836
- const SCENE_SETTINGS_TTL_MS = 3000;
837
- let sceneSettingsCache = null;
838
- async function readSceneSettings(force = false) {
839
- if (sceneSettingsCache && !force && Date.now() - sceneSettingsCache.at < SCENE_SETTINGS_TTL_MS)
840
- return sceneSettingsCache.value;
841
- await ensurePaths();
842
- const raw = await readJsonFile(hubPath(SCENE_SETTINGS_FILE));
843
- const value = normalizeSceneSettings(raw);
844
- sceneSettingsCache = { at: Date.now(), value };
845
- return value;
846
- }
847
- async function sceneSettingsOp(args) {
848
- const current = await readSceneSettings();
849
- if (!args || args.set !== true)
850
- return { ok: true, settings: current };
851
- const next = normalizeSceneSettings({
852
- enterPreview: typeof args.enterPreview === 'boolean' ? args.enterPreview : current.enterPreview,
853
- });
854
- await ensurePaths();
855
- return withWriteLock(async () => {
856
- try {
857
- await writeJsonFile(hubPath(SCENE_SETTINGS_FILE), next);
858
- }
859
- catch (e) {
860
- return { ok: false, error: '设置保存失败: ' + message(e) };
861
- }
862
- sceneSettingsCache = { at: Date.now(), value: next };
863
- return { ok: true, settings: next };
864
- });
865
- }
652
+ // 三份界面设置侧车(inject / scene / slash)拆到 ./settings-sidecars.ts:读走 3 秒 TTL 缓存,
653
+ // 写共用上面那条串行写锁。工厂只在这里调用一次,三份缓存仍是「每次 apply 一份」。
654
+ // 传的是 apply 作用域里那几个函数声明的引用(提升已初始化),不是快照值。
655
+ const { readInjectSettings, injectSettingsSync, injectNoticeOptions, injectSettingsOp, readSceneSettings, sceneSettingsOp, readSlashSettings, slashSettingsOp, } = createSettingsSidecars({
656
+ ensurePaths,
657
+ readJsonFile,
658
+ writeJsonFile,
659
+ withWriteLock,
660
+ message,
661
+ });
866
662
  // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的各域文本。
867
663
  // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
868
664
  let contextInjectorLive = null;
@@ -1011,158 +807,18 @@ export default {
1011
807
  return result;
1012
808
  };
1013
809
  }
1014
- /**
1015
- * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
1016
- * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
1017
- * 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
1018
- */
1019
- async function rebindSubagentInArchives(from, to) {
1020
- try {
1021
- const slice = await memoriesService.readArchiveSlice();
1022
- const touched = {};
1023
- for (const [scene, archive] of Object.entries(slice.archives || {})) {
1024
- const list = archive.subagents;
1025
- if (!Array.isArray(list) || !list.includes(from))
1026
- continue;
1027
- touched[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
1028
- }
1029
- const names = Object.keys(touched);
1030
- // 逐场景增量(审查 P2-3):此前整表回写,会把窗口期里别人保存的**别的场景**档案抹掉。
1031
- for (const scene of names)
1032
- await memoriesService.saveArchive(scene, touched[scene]);
1033
- return names.length;
1034
- }
1035
- catch {
1036
- return 0;
1037
- }
1038
- }
1039
- /**
1040
- * 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
1041
- * 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
1042
- * 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
1043
- */
1044
- async function renameSubagentInSnapshot(from, to) {
1045
- try {
1046
- const slice = await memoriesService.readArchiveSlice();
1047
- const mode = slice && slice.mode;
1048
- const snapshot = mode && mode.snapshot;
1049
- if (!snapshot)
1050
- return;
1051
- let changed = false;
1052
- const next = { ...snapshot };
1053
- if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
1054
- const all = {};
1055
- for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
1056
- all[k === from ? to : k] = v;
1057
- if (k === from)
1058
- changed = true;
1059
- }
1060
- next.subagentsAll = all;
1061
- }
1062
- for (const field of ['subagents', 'subagentsOn']) {
1063
- const list = snapshot[field];
1064
- if (Array.isArray(list) && list.includes(from)) {
1065
- next[field] = list.map((n) => (n === from ? to : n));
1066
- changed = true;
1067
- }
1068
- }
1069
- if (changed)
1070
- await memoriesService.patchIndex({ mode: { ...mode, snapshot: next } });
1071
- }
1072
- catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
1073
- }
1074
- const baseSubagentUpdate = subagentService.ops['subagent-update'];
1075
- if (typeof baseSubagentUpdate === 'function') {
1076
- subagentService.ops['subagent-update'] = async (args) => {
1077
- const res = await baseSubagentUpdate(args);
1078
- if (res && res.ok !== false && res.renamedFrom) {
1079
- const from = String(res.renamedFrom);
1080
- const to = String(res.name);
1081
- void rebindSubagentInArchives(from, to);
1082
- void renameSubagentInSnapshot(from, to);
1083
- }
1084
- return res;
1085
- };
1086
- }
1087
- // 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
1088
- // 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
1089
- const baseSceneModeSet = archiveService.ops['scene-mode-set'];
1090
- if (typeof baseSceneModeSet === 'function') {
1091
- archiveService.ops['scene-mode-set'] = async (args) => {
1092
- // 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
1093
- // 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
1094
- // 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
1095
- try {
1096
- const slice = await memoriesService.readArchiveSlice();
1097
- const current = slice.mode && slice.mode.scene;
1098
- if (current && args && 'scene' in (args || {})) {
1099
- const target = args.scene == null ? null : String(args.scene).trim() || null;
1100
- if (target !== current) {
1101
- const locked = await lockedSceneNames();
1102
- if (locked.includes(current)) {
1103
- return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
1104
- }
1105
- }
1106
- }
1107
- }
1108
- catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
1109
- const res = await baseSceneModeSet(args);
1110
- if (res && res.ok !== false) {
1111
- // 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
1112
- // 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
1113
- void subagentCatalog.refresh().catch(() => { });
1114
- void mcp.stateCatalog.refresh().catch(() => { });
1115
- }
1116
- return res;
1117
- };
1118
- }
1119
- /**
1120
- * 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
1121
- * (与进入场景时同一口径,见 archive-engine)——
1122
- * 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
1123
- * 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
1124
- * (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
1125
- */
1126
- const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
1127
- if (typeof baseSceneArchiveSave === 'function') {
1128
- archiveService.ops['scene-archive-save'] = async (args) => {
1129
- const res = await baseSceneArchiveSave(args);
1130
- try {
1131
- const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
1132
- const slice = await memoriesService.readArchiveSlice();
1133
- const mode = slice.mode;
1134
- if (mode && mode.scene === res.scene) {
1135
- const every = (await subagentService.list()).map((p) => p.name);
1136
- const unbound = every.filter((n) => bound.indexOf(n) < 0);
1137
- const toOn = await subagentService.enabledStore.disabledAmong(bound);
1138
- const toOff = await subagentService.enabledStore.enabledAmong(unbound);
1139
- if (toOn.length)
1140
- await subagentService.enabledStore.setEnabled(toOn, true);
1141
- if (toOff.length)
1142
- await subagentService.enabledStore.setEnabled(toOff, false);
1143
- if (toOn.length || toOff.length)
1144
- void subagentCatalog.refresh();
1145
- const snapshot = mode.snapshot;
1146
- // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
1147
- // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
1148
- // archive-engine 的 restoreSnapshot 兼容分支)。
1149
- if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
1150
- const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
1151
- const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
1152
- for (const n of toOn)
1153
- if (backOn.indexOf(n) < 0)
1154
- backOn.push(n);
1155
- for (const n of toOff)
1156
- if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
1157
- offList.push(n);
1158
- await memoriesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
1159
- }
1160
- }
1161
- }
1162
- catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
1163
- return res;
1164
- };
1165
- }
810
+ // ---------- 改名 / 进场景 / 存档案的联动(op 重包) ----------
811
+ // 正文 2026-10-01 整段搬到 ./scene-linkage.ts(一字未改,只把七个 apply 作用域名字改走
812
+ // deps)。调用点位置原样不动 —— 重包的是那四个 service 对象上的 ops 表,而下游(handlers
813
+ // 表、工具依赖表)取的是**当时那份函数**:包晚了,联动对它们就不存在。
814
+ // 两个**定义在本调用之后**的名字用 getter 传:`mcp` 在 MCP 管理域那一段才建、
815
+ // `lockedSceneNames` 在场景锁那一层才解构 —— 直接放进对象字面量会当场踩 TDZ,
816
+ // 而取用它们的时机是请求期,那时两个 const 都已初始化完。
817
+ installSceneLinkage({
818
+ memoriesService, subagentService, archiveService, quickPromptOps, subagentCatalog,
819
+ get mcp() { return mcp; },
820
+ get lockedSceneNames() { return lockedSceneNames; },
821
+ });
1166
822
  // HTTP 写 / 敏感 op 白名单(2026-09-19 抽到 ./request-gate.ts):四个 service 自报的
1167
823
  // writeOps,加上本文件内联域(mcpm-* / skill-open / agentsmd-* / history-*)。
1168
824
  const { WRITE_OPS, SENSITIVE_OPS } = createOpWhitelist({
@@ -1607,229 +1263,15 @@ export default {
1607
1263
  return { ...result, warning: result.warning + ';' + extra };
1608
1264
  return { ...result, warning: extra };
1609
1265
  }
1610
- /**
1611
- * 找到本插件 loader 行所在的补丁文件,并**按形状分类**。
1612
- *
1613
- * 冲突只认 **insert** 条目:同 id 的两条 insert 才会重复挂载、让 DSH 起不来(与
1614
- * duplicateGuard 同一口径)。顶层**覆盖条目**(`- id:` 在第 0 列)是官方支持的改法,
1615
- * 与 insert 并存是正常形状 —— 把它也算成"第二份补丁"会让 0.1.7 bundle 挂载下的令牌
1616
- * 操作被全量拒绝(2026-09-30 审查 P2-12)。
1617
- *
1618
- * `files` 的**顺序即写点优先级**:覆盖条目在前。理由:官方 `applyEntryPatches` 在所有
1619
- * bundle 层**之后**应用覆盖条目、且是整体替换那条 loader 的 `config` —— 所以两种形状
1620
- * 并存时,令牌写进 insert 行会被覆盖条目盖掉(改了不生效,两边还都看不出分歧)。
1621
- */
1622
- async function loaderTokenFiles() {
1623
- const p = await ensurePaths();
1624
- const insertFiles = [];
1625
- const overrideFiles = [];
1626
- const infoOf = new Map();
1627
- for (const abs of [p.projectPatch, p.globalPatch]) {
1628
- let content = '';
1629
- try {
1630
- content = await readPatch(abs);
1631
- }
1632
- catch {
1633
- continue;
1634
- }
1635
- const rows = scanLoaderRows(content);
1636
- if (!rows.insert.length && !rows.override.length)
1637
- continue;
1638
- const read = readLoaderToken(content);
1639
- if (!read.found)
1640
- continue;
1641
- infoOf.set(abs, { token: read.token, disabled: read.disabled });
1642
- if (rows.insert.length)
1643
- insertFiles.push(abs);
1644
- else
1645
- overrideFiles.push(abs);
1646
- }
1647
- // 0.1.7 bundle 挂载:loader 条目由插件包内的 cordis.patch.yml(bundle 层)提供,两份
1648
- // 补丁里都没有可就地改写的行。bundleBase 只回答「覆盖目标存在吗」—— 写点是 profile
1649
- // 补丁里的覆盖条目(见 tokenConfigure),bundle 文件本身永不落密钥。
1650
- let bundleBase = false;
1651
- try {
1652
- bundleBase = readLoaderToken(await readPatch(p.bundlePatch)).found;
1653
- }
1654
- catch {
1655
- bundleBase = false;
1656
- }
1657
- return { files: [...overrideFiles, ...insertFiles], insertFiles, infoOf, bundleBase };
1658
- }
1659
- /**
1660
- * 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
1661
- * 界面据此把"重启后生效"说成事实而不是猜测。
1662
- *
1663
- * 局限(写下来免得当成 bug):读不到环境变量,所以靠 `DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN`
1664
- * 提供令牌时(文件里没有 token),这里会报"待重启"。那是保守方向 —— 环境变量改动同样要重启。
1665
- */
1666
- async function tokenConfigState() {
1667
- try {
1668
- const { files, infoOf } = await loaderTokenFiles();
1669
- if (files.length !== 1)
1670
- return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
1671
- const info = infoOf.get(files[0]) || { token: '', disabled: false };
1672
- const configHasToken = info.token !== '';
1673
- // 「文件里配的」与「当前进程在用的」是否一致 —— 比的是**生效**状态(有位子但关了 = 没生效)。
1674
- const fileEffective = configHasToken && !info.disabled;
1675
- return { found: true, configHasToken, configDisabled: info.disabled, pendingRestart: fileEffective !== (TOKEN !== '') };
1676
- }
1677
- catch {
1678
- return { found: false, configHasToken: false, configDisabled: false, pendingRestart: false };
1679
- }
1680
- }
1681
- /**
1682
- * 改宿主侧的令牌配置:`set` 写入 / 换掉令牌、`off` 关掉令牌功能、`on` 重新打开、
1683
- * `clear` 把令牌从配置里删掉(回到"还没有令牌"那一态)。
1684
- *
1685
- * 关键设计(用户裁定 2026-09-19):
1686
- * - **`off` 不再删除 `token`**,只写一行 `tokenDisabled: true` —— 原令牌保留,随时能开回来,
1687
- * 不需要重新输一遍(此前的实现把配置删了,用户想再开就得重新想一遍令牌)。
1688
- * - **`clear` 才是"删掉"**(用户 2026-09-19 问"令牌没有彻底清除按钮"):删 `config.token`
1689
- * 与 `tokenDisabled` 两行,回到从未设置过的样子 —— 写操作不再要凭证、明文密钥随之
1690
- * 不可见。它是不可逆的那一端(配置里没有副本了),所以与 `off` 共用同一条凭证口径。
1691
- * - **`off` / `clear` 只认"当场再输一次"的令牌**(`value` 对得上才算):请求头里那份已验过
1692
- * 的凭证不算数 —— 解锁之后顺手一点就能把防护关掉 / 把令牌删掉,等于没把守(用户裁定
1693
- * 2026-09-19:「就算输入过了令牌,关闭保护也应该再次输入令牌才能关闭」)。界面据此就地在
1694
- * 「保护开关」/「宿主配置」里展开一个确认框。
1695
- * - **`set` 与 `off` / `clear` 同一条口径**:宿主已有令牌时,改令牌也必须当场再输一次当前
1696
- * 令牌(走 `current` 字段 —— `token` 里是新令牌,证明不了知道旧值;凭证是旧值这件事不能
1697
- * 靠请求头里那份"本次启动已解锁"顶替)。界面据此在「宿主配置」表单里多摆一栏「当前令牌」,
1698
- * 并且不再要求用户先解锁再改。
1699
- * - **`on` 仍接受请求头里的凭证**(`presented`),或输入框里那个值本身就是当前令牌。
1700
- * 两个方向都必须验 —— 否则"能打开 GUI 就能关掉保护",令牌等于白配(要求 9)。
1701
- * - `set`:宿主**还没配**令牌时允许(首次设置 —— 否则这个功能永远打不开),那一态没有
1702
- * "当前令牌"可证明,所以 `current` 不作要求。
1703
- *
1704
- * 不放进 handlers:这样它既不在 HTTP 的普通 op 面上,也**不会**被任何模型工具间接调用 ——
1705
- * 模型不该有能力关掉访问令牌。
1706
- */
1707
- async function tokenConfigure(args, presented) {
1708
- const raw = String((args && args.mode) || '');
1709
- const mode = raw === 'off' ? 'off' : raw === 'on' ? 'on' : raw === 'clear' ? 'clear' : 'set';
1710
- const value = String((args && args.token) || '').trim();
1711
- const currentRaw = String((args && args.current) || '').trim();
1712
- // 关闭 / 重新打开时,用户填进输入框的那个值**就是**当前令牌,可以直接当凭证 ——
1713
- // 省掉"先保存再操作"两步。`set` 不行:那个值是新令牌,不能拿它证明自己知道旧值,
1714
- // 所以它的凭证单独走 `current`。
1715
- const proofByValue = mode !== 'set' && value !== '' && CONFIG_TOKEN !== '' && tokenMatches(value);
1716
- const proofByCurrent = mode === 'set' && currentRaw !== '' && CONFIG_TOKEN !== '' && tokenMatches(currentRaw);
1717
- // 关闭保护 / 删除令牌 / 修改令牌都是"改凭证 / 减防护"的方向:**不认**请求头里已经验过的
1718
- // 那份凭证,必须当面再输一次当前令牌(用户裁定 2026-09-19 —— 先判「关闭」,随后同一口径
1719
- // 推到「修改」,再推到「删除」)。开启保护仍接受请求头里的凭证。
1720
- if (mode === 'off' && CONFIG_TOKEN !== '' && !proofByValue) {
1721
- return { ok: false, code: 'error.secret.badToken', error: '关闭保护要再输一次当前令牌。' };
1722
- }
1723
- if (mode === 'clear' && CONFIG_TOKEN !== '' && !proofByValue) {
1724
- return { ok: false, code: 'error.secret.badToken', error: '删除令牌要再输一次当前令牌。' };
1725
- }
1726
- if (mode === 'set' && CONFIG_TOKEN !== '' && !proofByCurrent) {
1727
- return { ok: false, code: 'error.secret.badToken', error: '修改令牌要先填一次当前令牌。' };
1728
- }
1729
- if (CONFIG_TOKEN !== '' && !presented && !proofByValue && !proofByCurrent) {
1730
- return { ok: false, code: 'error.secret.badToken', error: '要改动访问令牌,请先在「本次启动」里填入当前令牌并解锁。' };
1731
- }
1732
- if ((mode === 'off' || mode === 'clear') && CONFIG_TOKEN === '') {
1733
- return { ok: true, changed: false, note: '宿主侧本来就没有配置令牌。', restartRequired: false };
1734
- }
1735
- if (mode === 'on' && CONFIG_TOKEN === '') {
1736
- return { ok: false, error: '配置里没有令牌:请先用「设置令牌」写入一个,再打开令牌保护。' };
1737
- }
1738
- if (mode === 'set') {
1739
- if (!value)
1740
- return { ok: false, error: '请先填入要设置的令牌' };
1741
- // 换行会写坏 YAML 标量、NUL 会截断路径 —— 这两类直接拒绝比转义更清楚。
1742
- if (/[\r\n\u0000]/.test(value))
1743
- return { ok: false, error: '令牌不能包含换行或控制字符' };
1744
- if (value === CONFIG_TOKEN)
1745
- return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
1746
- }
1747
- const p = await ensurePaths();
1748
- const { files, insertFiles, bundleBase } = await loaderTokenFiles();
1749
- if (!files.length && !(mode === 'set' && bundleBase)) {
1750
- return { ok: false, error: '没找到本插件的 loader 行,也无法确认 bundle 层的挂载条目,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动加一条覆盖条目(- id: dsh-plugin-tool-management → config: → token: "…"),或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN。' };
1751
- }
1752
- // 只有**两条 insert 条目**才是"重复挂载、DSH 起不来"(审查 P2-12:覆盖条目与 insert
1753
- // 并存是 0.1.7 的正常形状,把它也算进来会让令牌操作被全量拒绝)。
1754
- if (insertFiles.length > 1) {
1755
- return { ok: false, error: '本插件的 loader 行作为 insert 条目同时出现在两份补丁文件里(重复挂载会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
1756
- }
1757
- // 覆盖条目多于一份则写点不唯一:官方按文件顺序 last-wins,我们无法确定该改哪一份 ——
1758
- // 与其赌一份,不如让用户先合并。
1759
- if (files.length > 1) {
1760
- return { ok: false, error: '本插件的 loader 行在多处出现且都不是唯一的 insert 条目(多半是多条覆盖条目):无法确定该改哪一处,请先手工合并成一条再设置令牌。' };
1761
- }
1762
- if (!files.length) {
1763
- // 0.1.7 bundle 挂载:两份补丁里都没有本插件的 loader 行(条目由 bundle 层提供,那份
1764
- // 文件是仓库镜像、不能落密钥)—— 令牌写进 **profile 补丁的覆盖条目**:宿主在所有
1765
- // bundle 层之后应用它,整体替换那条 loader 的 config(bundle 条目没有 config,替换
1766
- // 零丢失);同 id 的 insert 条目才会重复挂载导致启动失败,覆盖条目是官方支持的改法。
1767
- // `off` / `clear` / `on` 走不到这里:有 site 时就地改写,没 site 且有令牌 = 令牌来自
1768
- // 环境变量,那两条路在前面已按各自口径返回。
1769
- return withWriteLock(async () => {
1770
- let content = '';
1771
- try {
1772
- content = await readPatch(p.projectPatch);
1773
- }
1774
- catch (e) {
1775
- return { ok: false, error: '读取补丁失败: ' + message(e) };
1776
- }
1777
- const next = content === '' ? buildLoaderOverrideEntry(value) + '\n' : (content.endsWith('\n') ? content : content + '\n') + '\n' + buildLoaderOverrideEntry(value);
1778
- try {
1779
- await writePatch(p.projectPatch, next);
1780
- }
1781
- catch (e) {
1782
- return { ok: false, error: '写入补丁失败: ' + message(e) };
1783
- }
1784
- return { ok: true, changed: true, mode, restartRequired: true, path: p.projectPatch };
1785
- });
1786
- }
1787
- const abs = files[0];
1788
- return withWriteLock(async () => {
1789
- let content = '';
1790
- try {
1791
- content = await readPatch(abs);
1792
- }
1793
- catch (e) {
1794
- return { ok: false, error: '读取补丁失败: ' + message(e) };
1795
- }
1796
- // 两步改写共用一份内容:先写令牌(set 写值 / clear 删掉),再写开关(set 与 clear 顺带
1797
- // 把"已关闭"那行清掉 —— 换了个新令牌却还留着"已关闭"、或者令牌都没了还写着"已关闭",
1798
- // 都是说不通的)。
1799
- let changed = false;
1800
- // `refused`:loader 行里的 config 形状是插件读不了的(flow style / 重复键)。此时
1801
- // 改写会插入第二个 `config:`,而官方 js-yaml 对重复映射键是 throw —— 后果是 DSH 下次
1802
- // 起不来。所以拒绝并说明,而不是"尽力写一下"。
1803
- if (mode === 'set' || mode === 'clear') {
1804
- const wrote = applyLoaderToken(content, mode === 'clear' ? null : value);
1805
- if (!wrote.found)
1806
- return { ok: false, error: '没找到本插件的 loader 行:' + abs };
1807
- if (wrote.refused)
1808
- return { ok: false, error: wrote.refused };
1809
- content = wrote.content;
1810
- changed = changed || wrote.changed;
1811
- }
1812
- const flag = mode === 'off' ? true : mode === 'clear' ? null : false;
1813
- const toggled = applyLoaderTokenDisabled(content, flag);
1814
- if (!toggled.found)
1815
- return { ok: false, error: '没找到本插件的 loader 行:' + abs };
1816
- if (toggled.refused)
1817
- return { ok: false, error: toggled.refused };
1818
- content = toggled.content;
1819
- changed = changed || toggled.changed;
1820
- if (changed) {
1821
- try {
1822
- await writePatch(abs, content);
1823
- }
1824
- catch (e) {
1825
- return { ok: false, error: '写入补丁失败: ' + message(e) };
1826
- }
1827
- }
1828
- // 改配置**需要重启 DSH 才生效**:当前进程的 TOKEN 是 apply 时读进来的常量。
1829
- // 所以这里如实回 restartRequired,界面据此说清"现在还没生效",而不是让用户以为失败了。
1830
- return { ok: true, changed, mode, restartRequired: changed, path: abs };
1831
- });
1832
- }
1266
+ // ---------- 访问令牌:宿主侧配置的读写(兼容页就地开关) ----------
1267
+ // 正文 2026-10-01 整段搬到 ./token-config.ts(一字未改,只把八个 apply 作用域名字改走
1268
+ // deps)。这一段是 apply 里唯一**改写宿主启动配置**的手:四个方向的凭证判据、写点优先级、
1269
+ // 拒写形状全在那一层的注释里。这里按原位置建一次工厂 —— 它没有状态,两个出口仍然只被
1270
+ // apply 末尾的 HTTP 路由就地调用(刻意不进 handlers 表,模型关不掉访问令牌)。
1271
+ const { tokenConfigState, tokenConfigure } = createTokenConfig({
1272
+ ensurePaths, readPatch, writePatch, withWriteLock, message,
1273
+ TOKEN, CONFIG_TOKEN, tokenMatches,
1274
+ });
1833
1275
  // ---------- 通用侧车 JSON 读写 ----------
1834
1276
  // 为什么留在 index.ts:注入设置(inject-settings.json)也在用这一对,搬进
1835
1277
  // ./mcp/manager.ts 就得为一对 6 行函数再加一层 deps 转发。
@@ -2021,6 +1463,7 @@ export default {
2021
1463
  tools,
2022
1464
  readInjectSettings,
2023
1465
  injectSettingsOp,
1466
+ readSlashSettings,
2024
1467
  toolTableOp,
2025
1468
  toolTableReport: () => toolTableReport(),
2026
1469
  presetRoster: candidates.presetRoster,
@@ -2038,6 +1481,9 @@ export default {
2038
1481
  }),
2039
1482
  // AGENTS.md / 提示词预设域(ops/prompts.ts 提供,见上方 promptOps)。
2040
1483
  ...promptOps,
1484
+ // 快捷提示词域(ops/quick-prompts.ts):quickprompt-list / -create / -update /
1485
+ // -toggle / -remove 与回收站三条。它**不**碰 AGENTS.md、不进场景、不进注入。
1486
+ ...quickPromptOps,
2041
1487
  // 归档会话域(ops/history.ts 提供):history-list / history-sessions /
2042
1488
  // history-export-defaults / dir-list / history-archive / history-archive-batch /
2043
1489
  // history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
@@ -2057,15 +1503,24 @@ export default {
2057
1503
  createSession: agents?.create,
2058
1504
  host: (name) => ctx.get(name),
2059
1505
  promptsDir,
1506
+ quickPromptsDir,
2060
1507
  rulesList: (args) => memoriesService.ops['rules-list'](args),
2061
1508
  skillDetail: (args) => skillsService.ops['skill-detail'](args),
2062
- extractTurnsFromEvents,
2063
- serializeTurns,
1509
+ extractTurnsFromEvents: extractExportTurns,
1510
+ serializeTurns: serializeTranscript,
2064
1511
  }),
2065
1512
  // rules 域里要走「场景 ↔ 全局基线同步」包装的四个写 op(ops/scene-sync.ts 提供)。
2066
1513
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
2067
1514
  ...buildSceneSyncOps({
2068
1515
  withAgentsMdSync,
1516
+ // 「两轴恒等」(ops/scene-sync.ts 文件头):`rules-set-active` 要能把运行时跟着
1517
+ // 启用集合一起切。`scene-mode-set` 取**包过一层的那份**(锁定守卫 + 目录重算),
1518
+ // 且按调用时取(这里是组装期,联动在 931 行就已装好)。
1519
+ currentModeScene: async () => {
1520
+ const slice = await memoriesService.readArchiveSlice();
1521
+ return slice.mode && slice.mode.scene ? slice.mode.scene : null;
1522
+ },
1523
+ sceneModeSet: (args) => archiveService.ops['scene-mode-set'](args),
2069
1524
  onToolTablePresetChanged: async (scene, value) => {
2070
1525
  // 「修改场景」里改绑工具表方案且改的是**当前启用场景** → 立即把关停名单整体换成
2071
1526
  // 那一份(与「改档案 = 立刻生效」同口径)。解绑('')按下拉里的承诺 =「保持现状」,
@@ -2080,6 +1535,17 @@ export default {
2080
1535
  return;
2081
1536
  await applyToolTableHiddenNow(hidden);
2082
1537
  },
1538
+ // 快捷提示词:改的是**当前启用场景**时按新勾选集立刻应用一次 —— 走的就是「保存档案 =
1539
+ // 立刻生效」那条路(`scene-archive-save` 内含 stale 清理、运行时重应用与快照并入),
1540
+ // 不在这里另写一套应用逻辑。解绑(null)不回滚:这一域回到"保持现状",而进场景前那批
1541
+ // 开关仍在模式快照里,退出照常还原。
1542
+ onQuickPromptsChanged: async (scene, value) => {
1543
+ const slice = await memoriesService.readArchiveSlice();
1544
+ if (!slice.mode || slice.mode.scene !== scene || value === null)
1545
+ return;
1546
+ const cur = slice.archives[scene] || {};
1547
+ await archiveService.ops['scene-archive-save']({ scene, archive: { ...cur, quickPrompts: value } });
1548
+ },
2083
1549
  rulesOps: {
2084
1550
  'rules-set-active': (args) => memoriesService.ops['rules-set-active'](args),
2085
1551
  'rules-update-scene': (args) => memoriesService.ops['rules-update-scene'](args),
@@ -2105,6 +1571,9 @@ export default {
2105
1571
  // 场景页的界面设置(读 / 写):进入场景前要不要弹预览卡。按写操作门禁(它写侧车),
2106
1572
  // 但**不进场景冻结** —— 它是界面提示,与五个管理域的只读无关(见 scene-settings.ts)。
2107
1573
  'scene-settings': (args) => sceneSettingsOp(args),
1574
+ // 斜杠命令入口的开关(读 / 写):客户端 boot 时读一次决定挂不挂 `/` 菜单里的「工具」段,
1575
+ // 兼容页那两颗开关写一次。同 `scene-settings`:按写门禁但不冻结(见 slash-settings.ts)。
1576
+ 'slash-settings': (args) => slashSettingsOp(args),
2108
1577
  // state-doctor:跨域**悬空引用**体检(只读,实现与理由见 src/ops/state-doctor.ts)。
2109
1578
  // 这里只做"把权威集合取来"这一件事 —— 某一域读失败就传 null,体检会把它记进
2110
1579
  // `skipped` 而不是当成"该域没有悬空项"(读不到 ≠ 不存在,报成后者就是骗人)。
@@ -2450,6 +1919,18 @@ export default {
2450
1919
  next.subagents = toggleInList(Array.isArray(next.subagents) ? next.subagents.slice() : [], name, enabled);
2451
1920
  break;
2452
1921
  }
1922
+ case 'quickprompt-toggle': {
1923
+ // 与上面几段**不同**:这一段未定义说的是"这一域保持现状",不是"一个都没勾"。
1924
+ // 所以在没绑定的场景里手动勾一条快捷词,不该悄悄把它变成"按场景勾选"——
1925
+ // 那会让下一次进场景的行为整个换掉(用户看到的只是"我开了一个开关")。
1926
+ if (!Array.isArray(next.quickPrompts))
1927
+ return null;
1928
+ const id = String((args && args.id) || '');
1929
+ if (!id)
1930
+ return null;
1931
+ next.quickPrompts = toggleInList(next.quickPrompts.slice(), id, enabled);
1932
+ break;
1933
+ }
2453
1934
  default: return null;
2454
1935
  }
2455
1936
  }
@@ -2496,6 +1977,7 @@ export default {
2496
1977
  ensurePaths,
2497
1978
  pluginVersion: () => PKG_VERSION,
2498
1979
  promptsDir: () => promptsDir,
1980
+ quickPromptsDir: () => quickPromptsDir,
2499
1981
  message,
2500
1982
  });
2501
1983
  Object.assign(handlers, snapshotOps);
@@ -2920,227 +2402,15 @@ export default {
2920
2402
  mcp.dispose();
2921
2403
  }, 'dsh-plugin-tool-management: tool restriction cleanup');
2922
2404
  // ---------- HTTP API route (UI half), registered defensively ----------
2923
- if (webServer) {
2924
- // Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
2925
- // payloads (64 MiB of raw content). Reachability is decided by
2926
- // fenceRejection() below — the host's Host/Origin + browser-session fence
2927
- // (or a correct explicit token). `config.maxBodyBytes` overrides the cap
2928
- // (tests use a small value).
2929
- const configuredMaxBody = Number(config?.maxBodyBytes);
2930
- // 默认 88 MiB:够塞下一份带多张大附件(图片 / PDF)的技能或记忆打包体,又不至于让
2931
- // 一个失控的客户端把进程内存吃光。`config.maxBodyBytes` 可覆盖(测试用小值)。
2932
- const DEFAULT_MAX_BODY_BYTES = 88 * 1024 * 1024;
2933
- const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : DEFAULT_MAX_BODY_BYTES;
2934
- const readBody = (req) => new Promise((resolve, reject) => {
2935
- // 先看 content-length:注定超限的请求在**读第一个字节之前**就拒掉。以前只能边收边数,
2936
- // 一个 5 GB 的上传会先让我们缓冲满 88 MiB 才回错。读取流照旧 drain(一个字节都不留),
2937
- // 否则响应写完后服务端会直接拆连接,客户端可能在上传中途拿到网络错误而不是下面这句 JSON。
2938
- // 错误消息必须含 `body too large` —— 后面的 catch 靠这个子串分流。
2939
- const declared = Number(req.headers && req.headers['content-length']);
2940
- if (Number.isFinite(declared) && declared > MAX_BODY) {
2941
- const drain = req.resume;
2942
- if (typeof drain === 'function')
2943
- drain.call(req);
2944
- reject(new Error('request body too large'));
2945
- return;
2946
- }
2947
- // Accumulate Buffers and decode ONCE at the end: decoding each chunk
2948
- // separately corrupts multi-byte UTF-8 characters that straddle a
2949
- // chunk boundary (e.g. long Chinese skill bodies).
2950
- const chunks = [];
2951
- let size = 0;
2952
- req.on('data', (c) => {
2953
- const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
2954
- size += buf.length;
2955
- if (size > MAX_BODY) {
2956
- reject(new Error('request body too large'));
2957
- return;
2958
- }
2959
- chunks.push(buf);
2960
- });
2961
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2962
- req.on('error', reject);
2963
- });
2964
- // 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
2965
- // 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
2966
- // 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
2967
- // 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
2968
- // 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
2969
- // 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
2970
- const connection = (() => {
2971
- try {
2972
- return ctx.get('connection');
2973
- }
2974
- catch {
2975
- return undefined;
2976
- }
2977
- })();
2978
- try {
2979
- // ctx.effect wires the route's disposer into this plugin's scope, so an
2980
- // unload (HMR removal, disable, update) unregisters the route — the
2981
- // documented cleanup contract (webServer.register does not auto-scope).
2982
- ctx.effect(() => webServer.register({
2983
- kind: 'exact',
2984
- path: '/dsh-plugin-tool-management/api',
2985
- handler: async (req, res) => {
2986
- // 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
2987
- // (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
2988
- // LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权。
2989
- //
2990
- // 「未配置令牌时」别读成"仍有门禁":下面的写 op 判据是
2991
- // `if (TOKEN && WRITE_OPS.has(op))` —— `TOKEN === ''` 时整条短路,79 个写 op
2992
- // 只剩 `fenceRejection` 一道。而它自身有两种形态:宿主栅栏在场时含 cookie 鉴权;
2993
- // 栅栏缺席或抛错时退回本地判定,**只查回环 Host 与(若给了)Origin 自比对**,
2994
- // 没有 cookie 那一层 —— 本机任意进程都能调写 op。所以准确说法是:
2995
- // 未配置令牌时不再有「**无栅栏**放行」的缺口,但进程级边界只有配了令牌才有。
2996
- const hdr = (name) => {
2997
- const v = req.headers?.[name];
2998
- return Array.isArray(v) ? v[0] ?? '' : v ?? '';
2999
- };
3000
- if (String(req.method || 'POST').toUpperCase() !== 'POST') {
3001
- res.writeHead(405, { 'content-type': 'application/json' });
3002
- res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
3003
- return;
3004
- }
3005
- if (hdr('x-dsh-plugin') !== 'dsh-plugin-tool-management') {
3006
- res.writeHead(403, { 'content-type': 'application/json' });
3007
- res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
3008
- return;
3009
- }
3010
- // 「本次带的这串对不对」比的是**存量**令牌:关掉令牌功能之后仍然要凭它才能开回来、
3011
- // 换掉、或看明文(要求 9)。
3012
- const tokenAccepted = CONFIG_TOKEN !== '' && tokenMatches(hdr('x-dsh-token'));
3013
- // 验过就置闩(本次进程一次即可)。`agent/pre-step` 的令牌门禁读的就是它 ——
3014
- // 那一边看不到请求头,只能由这里告诉它"这台机器已经有人验过令牌了"。
3015
- if (tokenAccepted)
3016
- markAccepted();
3017
- if (!tokenAccepted) {
3018
- const fence = fenceRejection(req, connection);
3019
- if (fence) {
3020
- res.writeHead(fence.status, { 'content-type': 'application/json' });
3021
- res.end(JSON.stringify({ ok: false, error: fence.error }));
3022
- return;
3023
- }
3024
- }
3025
- res.writeHead(200, { 'content-type': 'application/json' });
3026
- try {
3027
- let payload = {};
3028
- try {
3029
- payload = JSON.parse((await readBody(req)) || '{}');
3030
- }
3031
- catch (e) {
3032
- if (String(e?.message).includes('body too large')) {
3033
- res.end(JSON.stringify({ ok: false, error: '请求体过大' }));
3034
- return;
3035
- }
3036
- /* otherwise fall through with {} */
3037
- }
3038
- const op = String(payload.op || '');
3039
- // 敏感 op(会吐明文密钥与完整配置):**配了令牌就必须带对的**,没配令牌则不设防
3040
- // (判定与理由见 http-fence.ts 的 secretOpRejection —— 用户裁定 2026-09-19:
3041
- // 没配令牌时那道门没有出路,用户无从填一个不存在的令牌)。
3042
- // 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
3043
- // 打开 GUI 的浏览器都拿得到全部密钥原文。
3044
- if (SENSITIVE_OPS.has(op)) {
3045
- // 明文门禁看的是**存量**令牌(CONFIG_TOKEN 而不是生效值 TOKEN):关掉令牌
3046
- // 功能之后仍然"要凭存量令牌才给明文"—— 那条路是通的(填对就能看),
3047
- // 与"从来没配过"不同。
3048
- const gate = secretOpRejection({ tokenConfigured: CONFIG_TOKEN !== '', tokenAccepted });
3049
- if (gate) {
3050
- res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
3051
- return;
3052
- }
3053
- }
3054
- // 「读改写」两态 op(`inject-settings` / `tool-table` / `scene-settings` /
3055
- // `mcpm-settings`):同一个名字既读又写,于是整条 op 被列进 WRITE_OPS —— 读侧也
3056
- // 一起被拦,界面表现是"没填令牌时整块设置消失、填了还要刷新才出现"(2026-09-19
3057
- // 用户报的)。判据是**这次调用要不要写**,不是 op 名在不在名单里。
3058
- // 判据本身在登记表(`writeWhen`),这里不再手写一份 —— 手写那份只认 `set:true`,
3059
- // 于是 `tool-table` 的 `presetSave` / `presetDelete` 被当成读放行,配了令牌也能
3060
- // 只凭宿主栅栏改侧车(审查 F2,0.17.0 修)。
3061
- const readOnlyCall = isReadCall(op, payload.args);
3062
- if (TOKEN && WRITE_OPS.has(op) && !readOnlyCall && !tokenMatches(hdr('x-dsh-token'))) {
3063
- // 文案与明文门禁同源(http-fence.ts),code 也统一 —— 界面据此在最右侧挂
3064
- // 「去填令牌」跳转按钮。这条错误会出现在**任意**页面,而入口只有一个。
3065
- res.end(JSON.stringify({ ok: false, code: TOKEN_CODE_BAD, error: TOKEN_MSG }));
3066
- return;
3067
- }
3068
- // 令牌状态(只读,**不需要**令牌本身):回答"宿主配没配"、"本次带的这串对不对"、
3069
- // "配置文件里的改动是不是还没重启生效"、"本次进程的标识"。就地回答而不是进 handlers 表:
3070
- // 前者的答案来自本次请求头与进程状态,而后者的 handlers 只拿得到 args。
3071
- // 不返回令牌、也不返回它的任何片段 —— 泄给同源页面等于把最后一道门交出去。
3072
- if (op === 'token-status') {
3073
- const state = await tokenConfigState();
3074
- res.end(JSON.stringify({
3075
- ok: true,
3076
- // 「宿主配置了令牌」= 配置里**有**值(关掉也算有:随时能开回来)。
3077
- hostConfigured: CONFIG_TOKEN !== '',
3078
- // 当前进程里令牌功能是开还是关(关掉 = 写操作不要求令牌)。
3079
- disabled: TOKEN_DISABLED,
3080
- // 「当前进程里令牌**在生效**」= 写操作要不要令牌。关掉或没配都是 false。
3081
- // 界面据此判断"填令牌"这件事有没有意义(没生效时填了也没人验,还会把
3082
- // 一句无处可填的提示挂在屏幕上 —— 用户 2026-09-19 报的就是这个)。
3083
- active: TOKEN !== '',
3084
- accepted: tokenAccepted,
3085
- bootId: BOOT_ID,
3086
- configHasToken: state.configHasToken,
3087
- configDisabled: state.configDisabled,
3088
- configFound: state.found,
3089
- pendingRestart: state.pendingRestart,
3090
- }));
3091
- return;
3092
- }
3093
- // 设置 / 关闭令牌:同样就地处理(理由见 tokenConfigure 的注释 —— 也正因为不进
3094
- // handlers,模型侧没有任何工具能间接关掉它)。
3095
- if (op === 'token-configure') {
3096
- res.end(JSON.stringify(withPatchWarnings(await tokenConfigure(payload.args || {}, tokenAccepted))));
3097
- return;
3098
- }
3099
- // 「清除令牌」的配套:把「本次启动已验过」的闩重新挂上。没有它,解锁一次之后
3100
- // 清除令牌 + 强刷新,浏览器里已无令牌,闩却挂着 —— pre-step 门禁读闩
3101
- // (tokenGateActive),整个启动期都放行对话(2026-09-19 用户实测的漏洞)。
3102
- // 不要求凭证:它只会收紧(要求重新验令牌),给不了任何人任何权限;同样不进
3103
- // handlers —— 模型不该有能力拨这颗闩。
3104
- if (op === 'token-unaccept') {
3105
- unmarkAccepted();
3106
- res.end(JSON.stringify({ ok: true }));
3107
- return;
3108
- }
3109
- const fn = handlers[op];
3110
- if (!fn) {
3111
- res.end(JSON.stringify({ ok: false, error: '未知操作: ' + op }));
3112
- return;
3113
- }
3114
- const result = await fn(payload.args || {});
3115
- // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
3116
- // 「这次算不算写」的判据在 recordOpAudit 里(与写门禁同源:登记表的 `writeWhen`)
3117
- // —— 不在这里再传一次 `readOnlyCall`,两处各判一次迟早会分家。
3118
- recordOpAudit(op, payload.args || {}, 'panel', result);
3119
- res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
3120
- }
3121
- catch (e) {
3122
- res.end(JSON.stringify({ ok: false, error: message(e) }));
3123
- }
3124
- },
3125
- }), 'dsh-plugin-tool-management: api route');
3126
- }
3127
- catch (e) {
3128
- // A registration failure must never take down the whole entry: log and continue.
3129
- console.error('[dsh-plugin-tool-management] webServer route registration failed:', message(e));
3130
- // 结构性弱点(2026-09-30 审查 §5 F4):compat-status **本身就走这条路由**,所以路由没注册上
3131
- // 时兼容页也拿不到这条降级行 —— 面板是**整页失联**,不是"少一行"。这里至少把它写进运行期
3132
- // 上报通道(日志里必然有一条,进程内可查),并在 README 里写明"面板空白先看宿主日志"。
3133
- // 之所以不是"必然可见"的告警:可见的通道就是这条路由,它自己挂了就没得显示。
3134
- noteRuntime({
3135
- id: 'web-server-route',
3136
- label: 'HTTP 路由注册',
3137
- kind: 'write',
3138
- fallback: 'inform-only',
3139
- detail: '向宿主 webServer 注册 API 路由失败(' + message(e) + '):插件面板与全部 HTTP op 不可用(兼容页也走这条路由,所以它也打不开)。宿主日志里有同一条记录;模型工具不受影响。',
3140
- detailKey: 'web-server-route.register-failed',
3141
- params: { reason: message(e) },
3142
- });
3143
- }
3144
- }
2405
+ // 正文 2026-10-01 整段搬到 ./http-api.ts(一字未改,只把 apply 作用域的名字改走 deps)。
2406
+ // 位置也保持原样:这一段是 apply 的最后一条注册,前面的 handlers 表 / 门禁 / 白名单都已就位。
2407
+ // 传引用而不是快照:`handlers` 在本调用之前还被 Object.assign 追加过、被 installHandlerGuards
2408
+ // 逐条重包过,注册之后请求期读的也是当时的最新那份。
2409
+ installHttpApi({
2410
+ ctx, config, webServer, handlers, message, withPatchWarnings,
2411
+ tokenConfigState, tokenConfigure,
2412
+ CONFIG_TOKEN, TOKEN_DISABLED, TOKEN, tokenMatches, BOOT_ID, markAccepted, unmarkAccepted,
2413
+ WRITE_OPS, SENSITIVE_OPS,
2414
+ });
3145
2415
  },
3146
2416
  };