dsh-plugin-tool-management 0.14.0 → 0.16.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.
- package/CHANGELOG.md +585 -519
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +409 -365
- package/lib/audit-log.js +157 -0
- package/lib/client.js +3915 -2545
- package/lib/compat/patch-dialect.js +3 -1
- package/lib/compat/preset-reach.js +37 -6
- package/lib/compat/probe.js +281 -11
- package/lib/context-inject.js +5 -3
- package/lib/index.js +236 -30
- package/lib/mcp/loader-token.js +53 -28
- package/lib/mcp/manager.js +311 -9
- package/lib/memories/archive-engine.js +70 -2
- package/lib/memories/archive.js +8 -1
- package/lib/memories/index-io.js +6 -0
- package/lib/memories/service.js +9 -0
- package/lib/op-registry.js +18 -4
- package/lib/ops/compat.js +121 -35
- package/lib/ops/scene-records.js +34 -0
- package/lib/ops/scene-sync.js +12 -1
- package/lib/ops/sessions.js +23 -8
- package/lib/ops/snapshot.js +596 -0
- package/lib/scenes/candidates.js +10 -5
- package/lib/sessions/bridge.js +5 -0
- package/lib/sessions/workspace.js +17 -5
- package/lib/skills/core.js +12 -3
- package/lib/tools/deps.js +1 -1
- package/lib/tools/table.js +5 -1
- package/package.json +113 -108
package/lib/mcp/manager.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MCP 管理域 —— 侧车读写(备注 / 设置 / 停用表)+ 补丁文件行编辑 +
|
|
1
|
+
// MCP 管理域 —— 侧车读写(备注 / 设置 / 停用表)+ 补丁文件行编辑 + 17 个 op 的实现。
|
|
2
2
|
//
|
|
3
3
|
// 2026-09-19 从 index.ts 的 apply 闭包原样抽出(块 A 1795-1913 + 块 B 1920-3100)。
|
|
4
4
|
// 为什么单独一个文件:这 1300 行只做一件事 —— 把「MCP 配置」翻译成补丁文件的改动与
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
// 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
|
|
19
19
|
// 由 deps 显式传入。
|
|
20
20
|
import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
|
|
21
|
+
// 配置体检查 PATH 用(见 mcpmInspect):execFile 不走 shell,参数是数组 —— 体检只查询
|
|
22
|
+
// 命令名,**绝不执行**配置里的命令本身。
|
|
23
|
+
import { execFile } from 'node:child_process';
|
|
21
24
|
import { MCP_CLIENT_MODULE } from '../host-names.js';
|
|
22
25
|
import { hubPath } from '../hub.js';
|
|
23
26
|
import { planOverrideCompaction } from './override-blocks.js';
|
|
@@ -524,6 +527,7 @@ export function createMcpManager(deps) {
|
|
|
524
527
|
kind: 'write',
|
|
525
528
|
fallback: 'inform-only',
|
|
526
529
|
detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
|
|
530
|
+
detailKey: 'mcp-tool-visibility.no-restrict',
|
|
527
531
|
});
|
|
528
532
|
return;
|
|
529
533
|
}
|
|
@@ -550,6 +554,8 @@ export function createMcpManager(deps) {
|
|
|
550
554
|
kind: 'write',
|
|
551
555
|
fallback: 'inform-only',
|
|
552
556
|
detail: '有工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
|
|
557
|
+
detailKey: 'mcp-tool-visibility.partial',
|
|
558
|
+
params: { reason: message(e) },
|
|
553
559
|
});
|
|
554
560
|
}
|
|
555
561
|
}
|
|
@@ -1487,12 +1493,118 @@ export function createMcpManager(deps) {
|
|
|
1487
1493
|
if (!id || (level !== 'global' && level !== 'project'))
|
|
1488
1494
|
return { ok: false, error: '缺少 id 或 level' };
|
|
1489
1495
|
const abs = level === 'global' ? p.globalPatch : p.projectPatch;
|
|
1490
|
-
|
|
1496
|
+
const removedScenes = [];
|
|
1497
|
+
const skippedLockedScenes = [];
|
|
1498
|
+
await withWriteLock(async () => {
|
|
1499
|
+
// serverName 在锁内取:场景档案(mcp / mcpNotes)与两张工具侧车表都按它键,
|
|
1500
|
+
// 补丁条目 id 是另一套;锁外读会和并发写竞态。
|
|
1501
|
+
const known = (await collectAll()).rows.find((r) => r.id === id);
|
|
1502
|
+
const serverName = known ? known.serverName : '';
|
|
1491
1503
|
let c = await readPatch(abs);
|
|
1492
1504
|
c = removeEntryAll(c, id);
|
|
1493
1505
|
await writePatch(abs, c);
|
|
1494
|
-
|
|
1506
|
+
// ── 同步清引用(best-effort,口径与 mcpm-edit 的改名同步一致)─────────────
|
|
1507
|
+
// 只删补丁行的话,引用它的档案/侧车全是死键:场景页会显示一台不存在的服务器,
|
|
1508
|
+
// 进/退场景还会去切换一个空条目;界面上则短暂残留宿主 loader 的 include:* 行。
|
|
1509
|
+
if (serverName) {
|
|
1510
|
+
try {
|
|
1511
|
+
const slice = await memoriesService.readArchiveSlice();
|
|
1512
|
+
let mode = slice.mode;
|
|
1513
|
+
// 运行时快照的 mcp 同样按 serverName 键:进场景时引擎按它关停/恢复,
|
|
1514
|
+
// 死键会在退场景回写时被无害地跳过 —— 但删了就该摘,与改名的 F-025 同口径。
|
|
1515
|
+
const snapMcp = mode && mode.snapshot && mode.snapshot.mcp;
|
|
1516
|
+
if (snapMcp && Object.prototype.hasOwnProperty.call(snapMcp, serverName)) {
|
|
1517
|
+
const rest = Object.assign({}, snapMcp);
|
|
1518
|
+
delete rest[serverName];
|
|
1519
|
+
mode = Object.assign({}, mode, { snapshot: Object.assign({}, mode.snapshot, { mcp: rest }) });
|
|
1520
|
+
}
|
|
1521
|
+
// 场景档案:被**锁定**的场景档案是用户显式冻结的,跳过并如实上报,
|
|
1522
|
+
// 不绕开门禁偷偷写;解锁后重新保存一次档案即可清掉死键。
|
|
1523
|
+
let locks = {};
|
|
1524
|
+
try {
|
|
1525
|
+
if (typeof memoriesService.sceneLocks === 'function')
|
|
1526
|
+
locks = (await memoriesService.sceneLocks()) || {};
|
|
1527
|
+
}
|
|
1528
|
+
catch { /* 锁态读不到时按未锁处理,与修复前的行为一致 */ }
|
|
1529
|
+
const archives = {};
|
|
1530
|
+
let archivesTouched = false;
|
|
1531
|
+
for (const [name, archive] of Object.entries(slice.archives || {})) {
|
|
1532
|
+
const a = archive || {};
|
|
1533
|
+
const hasMcp = !!(a.mcp && Object.prototype.hasOwnProperty.call(a.mcp, serverName));
|
|
1534
|
+
const hasNote = !!(a.mcpNotes && Object.prototype.hasOwnProperty.call(a.mcpNotes, serverName));
|
|
1535
|
+
if (!hasMcp && !hasNote) {
|
|
1536
|
+
archives[name] = archive;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (locks[name] === true) {
|
|
1540
|
+
skippedLockedScenes.push(name);
|
|
1541
|
+
archives[name] = archive;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
const copy = Object.assign({}, a);
|
|
1545
|
+
if (hasMcp) {
|
|
1546
|
+
const rest = Object.assign({}, a.mcp);
|
|
1547
|
+
delete rest[serverName];
|
|
1548
|
+
copy.mcp = rest;
|
|
1549
|
+
}
|
|
1550
|
+
if (hasNote) {
|
|
1551
|
+
const rest = Object.assign({}, a.mcpNotes);
|
|
1552
|
+
delete rest[serverName];
|
|
1553
|
+
// mcpNotes 全空 → null:与档案保存("全空 = 未定义")同一口径。
|
|
1554
|
+
copy.mcpNotes = Object.keys(rest).length ? rest : null;
|
|
1555
|
+
}
|
|
1556
|
+
archives[name] = copy;
|
|
1557
|
+
archivesTouched = true;
|
|
1558
|
+
removedScenes.push(name);
|
|
1559
|
+
}
|
|
1560
|
+
if (archivesTouched || mode !== slice.mode) {
|
|
1561
|
+
const patch = {};
|
|
1562
|
+
if (archivesTouched)
|
|
1563
|
+
patch.archives = archives;
|
|
1564
|
+
if (mode !== slice.mode)
|
|
1565
|
+
patch.mode = mode;
|
|
1566
|
+
await memoriesService.patchIndex(patch);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
catch { /* 清理失败不拦删除本身;死键无行为影响,与修复前一致 */ }
|
|
1570
|
+
try {
|
|
1571
|
+
// 停用表与「已知工具」也按 serverName 键:整台没了,键留着只会喂出错的口径
|
|
1572
|
+
// (补集里多出一台永远凑不上的服务器的旧工具名)。
|
|
1573
|
+
const toolMap = Object.assign({}, await readDisabledTools(true));
|
|
1574
|
+
if (Object.prototype.hasOwnProperty.call(toolMap, serverName)) {
|
|
1575
|
+
delete toolMap[serverName];
|
|
1576
|
+
await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
|
|
1577
|
+
disabledToolsCache = { at: Date.now(), value: toolMap };
|
|
1578
|
+
await applyToolRestrictions();
|
|
1579
|
+
}
|
|
1580
|
+
const knownMap = Object.assign({}, await readKnownMcpTools(true));
|
|
1581
|
+
if (Object.prototype.hasOwnProperty.call(knownMap, serverName)) {
|
|
1582
|
+
delete knownMap[serverName];
|
|
1583
|
+
await writeKnownMcpTools(knownMap);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
catch { /* non-fatal */ }
|
|
1587
|
+
try {
|
|
1588
|
+
// 备注按 loader id 键:条目删了备注就该走(writeNoteUnderLock 约定在写锁内调用)。
|
|
1589
|
+
await writeNoteUnderLock(id, '');
|
|
1590
|
+
}
|
|
1591
|
+
catch { /* non-fatal */ }
|
|
1592
|
+
}
|
|
1495
1593
|
});
|
|
1594
|
+
// 等 loader 把条目摘掉。等待放在**写锁外**(同 mcpm-restart:锁内等待会卡住其他写)。
|
|
1595
|
+
// 宿主清单偶尔停在上一帧,等不到**不算失败** —— 补丁条目确实已经删了,宿主侧收敛有
|
|
1596
|
+
// 自己的节奏;但要说出来,否则用户对着残留的 include:* 行只会以为插件没删干净。
|
|
1597
|
+
const warnings = [];
|
|
1598
|
+
if (pluginInventory) {
|
|
1599
|
+
const gone = await waitFor(async () => (await liveEntry(id)) === null, LOADER_STATE_WAIT_MS, LOADER_STATE_POLL_MS);
|
|
1600
|
+
if (!gone)
|
|
1601
|
+
warnings.push(`宿主 loader 清单未在 ${LOADER_STATE_WAIT_MS / 1000} 秒内摘除该条目,列表可能短暂残留它的行(宿主收敛后自动消失)`);
|
|
1602
|
+
}
|
|
1603
|
+
if (removedScenes.length)
|
|
1604
|
+
warnings.push(`已同步摘掉 ${removedScenes.length} 个场景档案里对它的引用(${removedScenes.join('、')})`);
|
|
1605
|
+
if (skippedLockedScenes.length)
|
|
1606
|
+
warnings.push(`场景 ${skippedLockedScenes.join('、')} 已锁定,档案里对它的引用保留未动(解锁后重新保存档案即可清掉)`);
|
|
1607
|
+
return warnings.length ? { ok: true, warning: warnings.join(';') } : { ok: true };
|
|
1496
1608
|
}
|
|
1497
1609
|
/**
|
|
1498
1610
|
* 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
|
|
@@ -1543,6 +1655,189 @@ export function createMcpManager(deps) {
|
|
|
1543
1655
|
return { ok: true, removed: total, ids: removed, files };
|
|
1544
1656
|
});
|
|
1545
1657
|
}
|
|
1658
|
+
// ---------- 配置体检(0.15.0 B1;0.16.1 起 streamable-http 含端点探测)--------------
|
|
1659
|
+
// 对补丁文件**只读**:不写盘、不拉起子进程。对外调用两处 ——
|
|
1660
|
+
// · `where` / `which` 查 PATH:只查「这个命令名能不能找到」,绝不执行配置里的命令本身;
|
|
1661
|
+
// · streamable-http 行对配置地址发一次 GET 连通性探测(带配置的鉴权头、5 秒超时、
|
|
1662
|
+
// 不读响应体 —— 与宿主连接该服务走的是同一地址同一头部,只是更轻)。
|
|
1663
|
+
//
|
|
1664
|
+
// 为什么单独读补丁而不复用 `mcpmList()`:那个函数顺带回写「已知工具」缓存、还要问一次
|
|
1665
|
+
// schema 注册表。体检的承诺是「点了什么都不变」,所以这里只走 readPatch + parseRows。
|
|
1666
|
+
const INSPECT_PATH_TIMEOUT_MS = 3000;
|
|
1667
|
+
/** 命令是否在 PATH 上:`unknown`(查不了)与 `missing`(确认没有)必须分开 —— 前者不是问题。 */
|
|
1668
|
+
async function probeCommandOnPath(command) {
|
|
1669
|
+
if (!command)
|
|
1670
|
+
return 'unknown';
|
|
1671
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
1672
|
+
return await new Promise((resolve) => {
|
|
1673
|
+
execFile(finder, [command], { timeout: INSPECT_PATH_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
|
|
1674
|
+
if (!error) {
|
|
1675
|
+
resolve(String(stdout || '').trim() ? 'ok' : 'missing');
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
// ENOENT = 连 where/which 自己都没跑到;killed = 超时。两种都是「无法确认」,
|
|
1679
|
+
// 剩下的(退出码非 0)才是「PATH 上没有这条命令」。
|
|
1680
|
+
const code = error.code;
|
|
1681
|
+
if (code === 'ENOENT' || error.killed === true)
|
|
1682
|
+
resolve('unknown');
|
|
1683
|
+
else
|
|
1684
|
+
resolve('missing');
|
|
1685
|
+
});
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
/** streamable-http 连通性探测的超时:再长,「全部检查」就会被一台挂掉的服务拖住。 */
|
|
1689
|
+
const INSPECT_PROBE_TIMEOUT_MS = 5000;
|
|
1690
|
+
/** 探测失败的原因短语(进词典模板的 {reason}):认得出常见错误码就说人话,认不出给原文截断。 */
|
|
1691
|
+
function describeProbeFailure(e) {
|
|
1692
|
+
const err = e;
|
|
1693
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
1694
|
+
return '探测超时(' + Math.round(INSPECT_PROBE_TIMEOUT_MS / 1000) + ' 秒无响应)';
|
|
1695
|
+
}
|
|
1696
|
+
const code = err && err.cause && err.cause.code;
|
|
1697
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN')
|
|
1698
|
+
return '域名解析失败(' + code + ')';
|
|
1699
|
+
if (code === 'ECONNREFUSED')
|
|
1700
|
+
return '连接被拒绝(' + code + ')';
|
|
1701
|
+
if (code === 'ETIMEDOUT')
|
|
1702
|
+
return '连接超时(' + code + ')';
|
|
1703
|
+
if (code === 'ECONNRESET')
|
|
1704
|
+
return '连接被重置(' + code + ')';
|
|
1705
|
+
if (typeof code === 'string' && /CERT|SSL|TLS/.test(code))
|
|
1706
|
+
return 'TLS 证书校验失败(' + code + ')';
|
|
1707
|
+
const text = String((err && err.cause && err.cause.message) || (err && err.message) || e || '');
|
|
1708
|
+
return text.length > 80 ? text.slice(0, 77) + '…' : text;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* streamable-http 的连通性探测:对配置地址 GET 一次,**任何** HTTP 响应(含 404/405)
|
|
1712
|
+
* 都算「可达」—— 探测只回答"这个地址现在连不连得上",不校验它是不是 MCP 服务。
|
|
1713
|
+
* 不读响应体:streamable-http 对 GET 常回一条 SSE 长连接,拿到状态行就取消。
|
|
1714
|
+
*/
|
|
1715
|
+
async function probeHttpEndpoint(url, headers) {
|
|
1716
|
+
const extra = {};
|
|
1717
|
+
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
|
1718
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1719
|
+
if (typeof v === 'string' && v)
|
|
1720
|
+
extra[k] = v;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
try {
|
|
1724
|
+
const res = await fetch(url, { method: 'GET', headers: extra, redirect: 'follow', signal: AbortSignal.timeout(INSPECT_PROBE_TIMEOUT_MS) });
|
|
1725
|
+
try {
|
|
1726
|
+
await res.body?.cancel();
|
|
1727
|
+
}
|
|
1728
|
+
catch { /* 取消失败不影响「可达」结论 */ }
|
|
1729
|
+
return { reachable: true };
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
return { reachable: false, reason: describeProbeFailure(e) };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
|
|
1736
|
+
async function inspectChecks(row, duplicateIds) {
|
|
1737
|
+
const checks = [];
|
|
1738
|
+
const declared = String(row.transport || '');
|
|
1739
|
+
const url = String(row.url || '');
|
|
1740
|
+
const command = String(row.command || '');
|
|
1741
|
+
// transport 缺失/不认识时按字段形状推:历史上手工改过补丁文件的行会绕过 normalize,
|
|
1742
|
+
// 直接判「没有 transport」会把一条其实能用的配置说成坏的。
|
|
1743
|
+
const transport = declared === 'stdio' || (!declared && command && !url) ? 'stdio'
|
|
1744
|
+
: declared === 'streamable-http' || (!declared && url) ? 'streamable-http'
|
|
1745
|
+
: declared;
|
|
1746
|
+
if (!declared && transport)
|
|
1747
|
+
checks.push({ id: 'transportMissing', level: 'info' });
|
|
1748
|
+
/** 值必须是字符串映射:非对象整片算坏,对象里逐个挑出非字符串的键。 */
|
|
1749
|
+
const badKv = (map) => {
|
|
1750
|
+
if (map == null)
|
|
1751
|
+
return [];
|
|
1752
|
+
if (typeof map !== 'object' || Array.isArray(map))
|
|
1753
|
+
return ['*'];
|
|
1754
|
+
return Object.keys(map).filter((k) => typeof map[k] !== 'string');
|
|
1755
|
+
};
|
|
1756
|
+
if (transport === 'stdio') {
|
|
1757
|
+
if (!command)
|
|
1758
|
+
checks.push({ id: 'noCommand', level: 'warn' });
|
|
1759
|
+
else {
|
|
1760
|
+
// 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
|
|
1761
|
+
// 不让用户以为命令坏了。
|
|
1762
|
+
const found = await probeCommandOnPath(command);
|
|
1763
|
+
if (found === 'missing')
|
|
1764
|
+
checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
|
|
1765
|
+
else if (found === 'unknown')
|
|
1766
|
+
checks.push({ id: 'cmdUnknown', level: 'info', params: { command } });
|
|
1767
|
+
}
|
|
1768
|
+
if (row.args != null && !Array.isArray(row.args))
|
|
1769
|
+
checks.push({ id: 'argsNotArray', level: 'warn' });
|
|
1770
|
+
const badEnv = badKv(row.env);
|
|
1771
|
+
if (badEnv.length)
|
|
1772
|
+
checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'env', keys: badEnv.join('、') } });
|
|
1773
|
+
}
|
|
1774
|
+
else if (transport === 'streamable-http') {
|
|
1775
|
+
if (!url)
|
|
1776
|
+
checks.push({ id: 'noUrl', level: 'warn' });
|
|
1777
|
+
else if (!/^https?:\/\//.test(url))
|
|
1778
|
+
checks.push({ id: 'badUrl', level: 'warn', params: { url } });
|
|
1779
|
+
else {
|
|
1780
|
+
// 格式对了再做一次真实探测(0.16.1):此前的体检止步于格式校验,一条编造的
|
|
1781
|
+
// 地址也能拿绿点 —— 绿点承诺的「没发现问题」其实只覆盖了一半(2026-09-29 实测)。
|
|
1782
|
+
const probe = await probeHttpEndpoint(url, row.headers);
|
|
1783
|
+
if (!probe.reachable)
|
|
1784
|
+
checks.push({ id: 'httpUnreachable', level: 'warn', params: { url, reason: probe.reason } });
|
|
1785
|
+
}
|
|
1786
|
+
const badHeaders = badKv(row.headers);
|
|
1787
|
+
if (badHeaders.length)
|
|
1788
|
+
checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
|
|
1789
|
+
}
|
|
1790
|
+
else if (!command && !url) {
|
|
1791
|
+
checks.push({ id: 'noEndpoint', level: 'warn' });
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
checks.push({ id: 'badTransport', level: 'warn', params: { transport } });
|
|
1795
|
+
}
|
|
1796
|
+
if (duplicateIds > 1)
|
|
1797
|
+
checks.push({ id: 'nameDup', level: 'warn', params: { name: String(row.serverName || ''), count: duplicateIds } });
|
|
1798
|
+
return checks;
|
|
1799
|
+
}
|
|
1800
|
+
async function mcpmInspect(args) {
|
|
1801
|
+
const p = await ensurePaths();
|
|
1802
|
+
const rows = [];
|
|
1803
|
+
const errors = [];
|
|
1804
|
+
for (const level of ['project', 'global']) {
|
|
1805
|
+
const abs = level === 'project' ? p.projectPatch : p.globalPatch;
|
|
1806
|
+
let content = '';
|
|
1807
|
+
try {
|
|
1808
|
+
content = await readPatch(abs);
|
|
1809
|
+
}
|
|
1810
|
+
catch (e) {
|
|
1811
|
+
errors.push(level + ': ' + message(e));
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
const { rows: fileRows } = parseRows(content);
|
|
1815
|
+
for (const r of fileRows)
|
|
1816
|
+
rows.push(normalizeRow(r, level, abs));
|
|
1817
|
+
}
|
|
1818
|
+
// 重名判据:同一个 serverName 挂在**不同 id** 下。同 id 跨层是正常遮蔽(界面已有「重复 id」标签),
|
|
1819
|
+
// 而按名字注册的 `mcp__<serverName>__*` 会撞车 —— 那才是配置问题。
|
|
1820
|
+
const idsByName = {};
|
|
1821
|
+
for (const r of rows) {
|
|
1822
|
+
const n = String(r.serverName || '');
|
|
1823
|
+
if (!idsByName[n])
|
|
1824
|
+
idsByName[n] = new Set();
|
|
1825
|
+
idsByName[n].add(String(r.id));
|
|
1826
|
+
}
|
|
1827
|
+
const onlyId = args && String(args.id || '') ? String(args.id) : '';
|
|
1828
|
+
const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
|
|
1829
|
+
// 逐行检查并行跑:体检含 streamable-http 的端点探测(每次至多 5 秒超时),串行会让
|
|
1830
|
+
// 「全部检查」在多台同时连不上时按行数翻倍地慢。Promise.all 保序,与旧实现一致。
|
|
1831
|
+
const targets = rows.filter((r) => (!onlyId || String(r.id) === onlyId) && (!onlyLevel || String(r.level) === onlyLevel));
|
|
1832
|
+
const results = await Promise.all(targets.map(async (r) => ({
|
|
1833
|
+
id: r.id,
|
|
1834
|
+
serverName: r.serverName,
|
|
1835
|
+
level: r.level,
|
|
1836
|
+
transport: r.transport || null,
|
|
1837
|
+
checks: await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size),
|
|
1838
|
+
})));
|
|
1839
|
+
return { ok: true, results, errors };
|
|
1840
|
+
}
|
|
1546
1841
|
async function mcpmExport() {
|
|
1547
1842
|
const p = await ensurePaths();
|
|
1548
1843
|
const list = await mcpmList();
|
|
@@ -1789,6 +2084,7 @@ export function createMcpManager(deps) {
|
|
|
1789
2084
|
'mcpm-compact': mcpmCompact,
|
|
1790
2085
|
'mcpm-export': mcpmExport,
|
|
1791
2086
|
'mcpm-import': mcpmImport,
|
|
2087
|
+
'mcpm-inspect': mcpmInspect,
|
|
1792
2088
|
},
|
|
1793
2089
|
readDisabledTools,
|
|
1794
2090
|
readKnownMcpTools,
|
|
@@ -1799,13 +2095,19 @@ export function createMcpManager(deps) {
|
|
|
1799
2095
|
mcpmRowsWithNotes,
|
|
1800
2096
|
readPluginSettings,
|
|
1801
2097
|
isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),
|
|
1802
|
-
/**
|
|
1803
|
-
|
|
2098
|
+
/** 停用表的两个口径(读 TTL 缓存;功能总览用):`*` 通配的台数 + 单独停用的工具数。 */
|
|
2099
|
+
disabledToolSummary: () => {
|
|
1804
2100
|
const map = disabledToolsCache ? disabledToolsCache.value : {};
|
|
1805
|
-
let
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
2101
|
+
let servers = 0;
|
|
2102
|
+
let tools = 0;
|
|
2103
|
+
for (const list of Object.values(map)) {
|
|
2104
|
+
if (!Array.isArray(list))
|
|
2105
|
+
continue;
|
|
2106
|
+
if (list.indexOf('*') >= 0)
|
|
2107
|
+
servers += 1;
|
|
2108
|
+
tools += list.filter((name) => name !== '*').length;
|
|
2109
|
+
}
|
|
2110
|
+
return { servers, tools };
|
|
1809
2111
|
},
|
|
1810
2112
|
warmUp,
|
|
1811
2113
|
scheduleToolRestrictions,
|
|
@@ -108,6 +108,11 @@ export function createArchiveEngine(deps) {
|
|
|
108
108
|
await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
// 模型工具表(C1):按快照原值整体回写。老快照没有这一栏 → 一个字都不动(与进场景前一致)。
|
|
112
|
+
if (snapshot.toolTableHidden) {
|
|
113
|
+
await deps.applyToolTableHidden(snapshot.toolTableHidden);
|
|
114
|
+
}
|
|
115
|
+
deps.audit?.('scene-restore', '');
|
|
111
116
|
}
|
|
112
117
|
/**
|
|
113
118
|
* 失败回滚:运行时还原 + 切片写回;每步失败都记下来,绝不谎报「已回滚」。
|
|
@@ -172,6 +177,15 @@ export function createArchiveEngine(deps) {
|
|
|
172
177
|
if (entries.length)
|
|
173
178
|
await deps.applyMcpNotes(entries);
|
|
174
179
|
}
|
|
180
|
+
// 工具表方案(C1):改档案 = 立刻生效,这一域也得跟上。快照里的原值**不动** ——
|
|
181
|
+
// 退出场景仍然回到「进场景前」那份名单,而不是回到上一次档案的方案。
|
|
182
|
+
const reapplyPreset = (archive && archive.toolTablePreset) || '';
|
|
183
|
+
if (reapplyPreset) {
|
|
184
|
+
const target = await deps.toolTablePresetHidden(reapplyPreset);
|
|
185
|
+
if (target)
|
|
186
|
+
await deps.applyToolTableHidden(target);
|
|
187
|
+
}
|
|
188
|
+
deps.audit?.('scene-apply', '');
|
|
175
189
|
return { mcpServers: mcpPlan.serverSwitches, skillSources: skillsPlan.sourceSwitches };
|
|
176
190
|
}
|
|
177
191
|
const ops = {
|
|
@@ -343,6 +357,21 @@ export function createArchiveEngine(deps) {
|
|
|
343
357
|
for (const s of skillsPlan.sourceSwitches)
|
|
344
358
|
(s.enabled ? sourcesOn : sourcesOff).push(s.root);
|
|
345
359
|
const stale = [...mcpPlan.stale, ...skillsPlan.stale];
|
|
360
|
+
// 工具表方案(C1):绑了就说清「会切成哪一份、关几条」。方案在别处被删掉 / 换机器之后
|
|
361
|
+
// 查不到时 hiddenCount 给 null,界面那句会改成"方案已不存在,这次不会切换"——
|
|
362
|
+
// 比让预览卡继续显示一条看不懂的数字诚实。
|
|
363
|
+
const previewPreset = (archive && archive.toolTablePreset) || '';
|
|
364
|
+
let toolPreset = null;
|
|
365
|
+
if (previewPreset) {
|
|
366
|
+
let list = null;
|
|
367
|
+
try {
|
|
368
|
+
list = await deps.toolTablePresetHidden(previewPreset);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
return { ok: false, error: `读取模型工具表失败:${msg(e)}` };
|
|
372
|
+
}
|
|
373
|
+
toolPreset = { name: previewPreset, hiddenCount: list ? list.length : null };
|
|
374
|
+
}
|
|
346
375
|
const notesDefined = !!(archive && archive.mcpNotes);
|
|
347
376
|
const noteCount = notesDefined
|
|
348
377
|
? Object.keys(archive.mcpNotes).filter((server) => archive.mcp && server in archive.mcp).length
|
|
@@ -375,6 +404,7 @@ export function createArchiveEngine(deps) {
|
|
|
375
404
|
sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
|
|
376
405
|
},
|
|
377
406
|
subagents: { on: clip(personas.on), off: clip(personas.off) },
|
|
407
|
+
toolPreset,
|
|
378
408
|
stale: clip(stale),
|
|
379
409
|
truncated,
|
|
380
410
|
};
|
|
@@ -433,12 +463,29 @@ export function createArchiveEngine(deps) {
|
|
|
433
463
|
if (hadKeys && archive.subagents.length === 0)
|
|
434
464
|
delete archive.subagents;
|
|
435
465
|
}
|
|
466
|
+
if (archive.toolTablePreset) {
|
|
467
|
+
// 方案名查不到(被删了 / 档案是从别的机器带来的)→ 丢弃这一栏并报告:
|
|
468
|
+
// 绑一个不存在的方案不该在进场景时变成一句"方案不存在"的硬失败。
|
|
469
|
+
const found = await deps.toolTablePresetHidden(archive.toolTablePreset);
|
|
470
|
+
if (!found) {
|
|
471
|
+
stale.push('toolTable/' + archive.toolTablePreset);
|
|
472
|
+
delete archive.toolTablePreset;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
436
475
|
// 记忆段(P5 已废弃):不再写入、不再校验。记忆的开关是 `rules[*].enabled` 单一真相源,
|
|
437
476
|
// 老档案里残留的 `memories` 字段被忽略(不迁移、不删除,回滚代码时仍可读)。
|
|
438
477
|
// 保存档案时顺手把残留字段清掉,避免新旧语义并存造成误读。
|
|
439
478
|
if (archive.memories !== undefined)
|
|
440
479
|
delete archive.memories;
|
|
441
480
|
const slice = await deps.loadSlice();
|
|
481
|
+
// 工具表方案的绑定入口已改到「修改场景」表单(0.15.0 用户裁定):档案保存的载荷
|
|
482
|
+
// 不再携带这一栏,**不带 ≠ 解绑** —— 保留现值(解绑走 rules-update-scene 的空串;
|
|
483
|
+
// 带上来的值仍走上面的 stale 校验,保留的现值由进出场景时的 stale 处理兜底)。
|
|
484
|
+
if (archive.toolTablePreset === undefined) {
|
|
485
|
+
const prev = slice.archives[scene];
|
|
486
|
+
if (prev && typeof prev.toolTablePreset === 'string' && prev.toolTablePreset)
|
|
487
|
+
archive.toolTablePreset = prev.toolTablePreset;
|
|
488
|
+
}
|
|
442
489
|
if (Object.keys(archive).length === 0)
|
|
443
490
|
delete slice.archives[scene];
|
|
444
491
|
else
|
|
@@ -590,11 +637,28 @@ export function createArchiveEngine(deps) {
|
|
|
590
637
|
return { ok: false, error: `读取备注状态失败(未改动任何东西):${msg(e)}` };
|
|
591
638
|
}
|
|
592
639
|
}
|
|
640
|
+
// 工具表方案(C1):**改任何东西之前**先把现状读出来 —— 读失败就等于什么都没动。
|
|
641
|
+
// 没绑定方案就完全不碰这一域(也不往快照里加栏),老场景行为逐字节不变。
|
|
642
|
+
const toolTablePreset = (archive && archive.toolTablePreset) || '';
|
|
643
|
+
let toolTableBefore = null;
|
|
644
|
+
let toolTableTarget = null;
|
|
645
|
+
if (toolTablePreset) {
|
|
646
|
+
try {
|
|
647
|
+
toolTableBefore = await deps.currentToolTableHidden();
|
|
648
|
+
toolTableTarget = await deps.toolTablePresetHidden(toolTablePreset);
|
|
649
|
+
}
|
|
650
|
+
catch (e) {
|
|
651
|
+
return { ok: false, error: `读取模型工具表现状失败(未改动任何东西):${msg(e)}` };
|
|
652
|
+
}
|
|
653
|
+
if (!toolTableTarget) {
|
|
654
|
+
return { ok: false, error: `场景绑定的工具表方案「${toolTablePreset}」已不存在,未进入场景(先在场景档案里换一份或取消绑定)` };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
593
657
|
// 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
|
|
594
658
|
// 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
|
|
595
659
|
// 上层两行传**现状全量**(上面的 mcpServerStates / skillSources 就是进场景前的值),
|
|
596
660
|
// 不是计划里那几行 —— 退出按「现状 ≠ 记录值」回写(见 mcpServerRowsToRestore)。
|
|
597
|
-
const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates);
|
|
661
|
+
const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore);
|
|
598
662
|
const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
|
|
599
663
|
try {
|
|
600
664
|
await deps.saveSlice({ ...slice, mode: entered.mode });
|
|
@@ -629,6 +693,10 @@ export function createArchiveEngine(deps) {
|
|
|
629
693
|
if (noteTargets && noteTargets.length) {
|
|
630
694
|
await deps.applyMcpNotes(noteTargets.map((x) => ({ id: x.id, note: x.note })));
|
|
631
695
|
}
|
|
696
|
+
// ⑤ 模型工具表:档案绑了方案就把关停名单**整体换成**那份(省下的 token 按轮算,
|
|
697
|
+
// 所以它是"进入场景"这一动作里唯一直接改每请求内容的域)。
|
|
698
|
+
if (toolTableTarget)
|
|
699
|
+
await deps.applyToolTableHidden(toolTableTarget);
|
|
632
700
|
}
|
|
633
701
|
catch (e) {
|
|
634
702
|
return await rollback(slice, snapshot, '应用档案', e);
|
|
@@ -645,7 +713,7 @@ export function createArchiveEngine(deps) {
|
|
|
645
713
|
mode: entered.mode,
|
|
646
714
|
// 三个域都是「按勾选集全量应用」(未定义 = 全关),所以都算已应用;
|
|
647
715
|
// 备注段未定义 = 不覆盖任何备注,如实报它是否定义。
|
|
648
|
-
applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes) },
|
|
716
|
+
applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget },
|
|
649
717
|
// 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
|
|
650
718
|
switched: {
|
|
651
719
|
mcpServers: mcpPlan.serverSwitches.length,
|
package/lib/memories/archive.js
CHANGED
|
@@ -66,6 +66,10 @@ export function normalizeArchive(raw) {
|
|
|
66
66
|
out.subagents = normalizeStringList(obj.subagents);
|
|
67
67
|
if (obj.memories != null)
|
|
68
68
|
out.memories = normalizeStringList(obj.memories);
|
|
69
|
+
// 工具表方案是**标量**不是集合:空串 / 非字符串一律视为未绑定(段不存在)。
|
|
70
|
+
const toolTablePreset = typeof obj.toolTablePreset === 'string' ? obj.toolTablePreset.trim() : '';
|
|
71
|
+
if (toolTablePreset)
|
|
72
|
+
out.toolTablePreset = toolTablePreset;
|
|
69
73
|
return out;
|
|
70
74
|
}
|
|
71
75
|
export function hasSection(archive, section) {
|
|
@@ -202,7 +206,9 @@ mcpNotes = [],
|
|
|
202
206
|
* 进入模式时**全部**人设的开关状态(名字 → 是否开着)。给了就写进 `subagentsAll`:
|
|
203
207
|
* 退出时按它精确还原(场景里手动开过的人设也会被还原回进场景前的状态)。
|
|
204
208
|
*/
|
|
205
|
-
subagentStates = null
|
|
209
|
+
subagentStates = null,
|
|
210
|
+
/** 进场景前的模型工具表关停名单(null = 这次不进快照,退出也就不动工具表)。 */
|
|
211
|
+
toolTableHidden = null) {
|
|
206
212
|
return {
|
|
207
213
|
mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
|
|
208
214
|
skills: { ...skills },
|
|
@@ -212,6 +218,7 @@ subagentStates = null) {
|
|
|
212
218
|
...(subagentsOn.length ? { subagentsOn: subagentsOn.slice() } : {}),
|
|
213
219
|
...(subagentStates ? { subagentsAll: { ...subagentStates } } : {}),
|
|
214
220
|
...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
|
|
221
|
+
...(toolTableHidden ? { toolTableHidden: toolTableHidden.slice() } : {}),
|
|
215
222
|
};
|
|
216
223
|
}
|
|
217
224
|
// ── 记忆勾选(v3)──────────────────────────────────────────────────────────
|
package/lib/memories/index-io.js
CHANGED
|
@@ -157,6 +157,11 @@ export function parseModeState(raw) {
|
|
|
157
157
|
.filter((x) => !!x && typeof x === 'object')
|
|
158
158
|
.map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
|
|
159
159
|
.filter((x) => x.id !== '');
|
|
160
|
+
// v0.15 的模型工具表原值名单:**同样必须原样透传**(剥掉它 = 退出场景后工具表停在方案那份,
|
|
161
|
+
// 与历史上 mcpServers / mcpNotes 被剥掉是同一类 bug)。老快照没有这一栏 → null = 不还原。
|
|
162
|
+
const toolTableHidden = Array.isArray(snapshotRaw.toolTableHidden)
|
|
163
|
+
? snapshotRaw.toolTableHidden.map((x) => String(x))
|
|
164
|
+
: null;
|
|
160
165
|
return {
|
|
161
166
|
scene,
|
|
162
167
|
snapshot: scene
|
|
@@ -169,6 +174,7 @@ export function parseModeState(raw) {
|
|
|
169
174
|
...(subagentsOn.length ? { subagentsOn } : {}),
|
|
170
175
|
...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
|
|
171
176
|
...(mcpNotes.length ? { mcpNotes } : {}),
|
|
177
|
+
...(toolTableHidden ? { toolTableHidden } : {}),
|
|
172
178
|
}
|
|
173
179
|
: null,
|
|
174
180
|
};
|
package/lib/memories/service.js
CHANGED
|
@@ -696,6 +696,15 @@ export function createMemoriesService(ctx, deps) {
|
|
|
696
696
|
refresh,
|
|
697
697
|
patchIndex,
|
|
698
698
|
readArchiveSlice,
|
|
699
|
+
sceneLocks: async () => {
|
|
700
|
+
const index = await readIndex(stateDir);
|
|
701
|
+
const out = {};
|
|
702
|
+
for (const [name, rec] of Object.entries(index.scenes || {})) {
|
|
703
|
+
if (rec && rec.locked === true)
|
|
704
|
+
out[name] = true;
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
},
|
|
699
708
|
};
|
|
700
709
|
return service;
|
|
701
710
|
}
|
package/lib/op-registry.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// "会不会改宿主或外部系统的状态",而上面那些写的是插件自己的侧车(丢了可重建)。
|
|
32
32
|
// 想要它们也带令牌就显式改成 write,别靠"读操作带副作用"这句话推。
|
|
33
33
|
export const OP_REGISTRY = Object.freeze({
|
|
34
|
-
// ── MCP 域(
|
|
34
|
+
// ── MCP 域(17)────────────────────────────────────────────────────────────
|
|
35
35
|
'mcpm-list': { annotatesLock: true },
|
|
36
36
|
// reveal 返回**未打码**的凭据。它是读操作,但按写门禁 —— 局域网暴露的端口上,令牌对
|
|
37
37
|
// 明文凭据必须是最后一道防线,而不只对着写操作。
|
|
@@ -55,6 +55,9 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
55
55
|
// 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
|
|
56
56
|
'mcpm-tools-refresh': { write: true },
|
|
57
57
|
'mcpm-tools': { readonly: true },
|
|
58
|
+
// 配置体检:只读补丁文件 + 一次 PATH 查询(`where`/`which`,不执行配置里的命令)。
|
|
59
|
+
// 返回的是检查项 id,不回任何字段值 —— 与 `mcpm-list` 同档,不带令牌。
|
|
60
|
+
'mcpm-inspect': { readonly: true },
|
|
58
61
|
// ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
|
|
59
62
|
'skill-state': { annotatesLock: true },
|
|
60
63
|
'skill-detail': { readonly: true },
|
|
@@ -164,15 +167,26 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
164
167
|
'bundle-export': { write: true },
|
|
165
168
|
// 新增一条宿主工作区登记 —— 改的是宿主侧的登记,不是插件自己的侧车。
|
|
166
169
|
'history-workspace-register': { write: true },
|
|
167
|
-
// ── 兼容 / 注入 /
|
|
170
|
+
// ── 兼容 / 注入 / 备份 / 流水 / 迁移快照(11)────────────────────────────────
|
|
168
171
|
'compat-status': { readonly: true },
|
|
172
|
+
// 「最近改动」流水的读侧:只读 hub 里的 `audit.jsonl`(内容只有对象名,见 audit-log.ts 第 ① 条)。
|
|
173
|
+
'audit-list': { readonly: true },
|
|
174
|
+
// 清空流水:删的是插件自己的文件,但它是**一次改动**(清完还会留下一条 `audit-clear`),按写门禁。
|
|
175
|
+
'audit-clear': { write: true },
|
|
176
|
+
// 整机迁移快照(0.15.0 C2)。export 标 sensitive 与 `mcpm-export` 同档:`includeSecrets` 时
|
|
177
|
+
// 它交出的就是明文凭据;不配令牌时不设防(判据见 http-fence.ts 的 secretOpRejection)。
|
|
178
|
+
'snapshot-export': { write: true, sensitive: true },
|
|
179
|
+
'snapshot-preview': { readonly: true },
|
|
180
|
+
// 逐域调各域自己的 import op —— 那些 op 本来就被场景冻结挡着,这里不重复标 frozen,
|
|
181
|
+
// 免得两道锁口径分家(预览与执行都走同一套门禁)。
|
|
182
|
+
'snapshot-import': { write: true },
|
|
169
183
|
'feature-overview': { readonly: true },
|
|
170
184
|
'preset-reach': { readonly: true },
|
|
171
|
-
//
|
|
185
|
+
// 全部注入域的当前正文**全文**都从这里出去。今天不带令牌就能读,是这批只读里披露面最大的一条;
|
|
172
186
|
// 要不要上门禁是产品决定,登记在这里是为了让它可数。
|
|
173
187
|
'injection-live': { readonly: true },
|
|
174
188
|
'backups-list': { readonly: true },
|
|
175
|
-
//
|
|
189
|
+
// 注入设置(各域开关 / 压制型预设口径)写侧车。它是"配置"不是"宿主状态",但改的是
|
|
176
190
|
// 投递语义,按写操作门禁。
|
|
177
191
|
'inject-settings': { write: true },
|
|
178
192
|
// 模型工具表(哪些工具根本不发给模型)也写侧车,改的是每轮请求的内容 —— 同上按写门禁。
|