dsh-armory 2.4.3 → 2.4.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.
package/lib/index.js CHANGED
@@ -5,24 +5,32 @@ import { homedir } from "node:os";
5
5
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
6
  import { interpolate } from "@deepseek-ai/cordis-plugin-loader";
7
7
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
8
- import { basename, dirname, join } from "node:path";
8
+ import { basename, dirname, isAbsolute, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { normalizeMcpImport, readManagedMcpServers, setManagedMcpDisabled, updateManagedMcpPatch } from "./mcp-config.js";
11
+ import { describeLaunchRewrite } from "./win32-stdio.js";
11
12
  import { admitImportedServers } from "./import-admission.js";
12
13
  import { collectImportLocations, missingEnvNames, resolveImportLocation } from "./mcp-import-sources.js";
13
14
  import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
14
15
  import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
15
16
  // Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
16
17
  import { removeSkillFromDisk } from "./skill-remove.js";
18
+ // 工具裁缝技能账本的禁用/释放落盘点:写 SKILL.md frontmatter 的 disable-model-invocation
19
+ // 标志(宿主 dsh-skill-filesystem 官方键),安全闸门与 skill-remove 同一套(lib/skill-disable.js)。
20
+ import { setSkillModelInvocationOnDisk } from "./skill-disable.js";
17
21
  // Skill 看板的安装时间 + 市场原名投影(名字自建映射,lib/skill-dir-times.js)。
18
22
  import { skillInstallInfo } from "./skill-dir-times.js";
23
+ // 系统级打开(issue #7):headless 主机没有 xdg-open,spawn 的 ENOENT 必须有监听者,
24
+ // 否则异步 error 事件会带走整个宿主进程(lib/system-open.js)。
25
+ import { isAllowedExternalUrl, openSystemUrl, revealSystemPath } from "./system-open.js";
19
26
  import { WHATS_NEW } from "./whats-new.js";
20
27
  import { attachWhatsNewEnglish } from "./whats-new-en.js";
21
- import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorIcon, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
22
28
  // T03/R4:多市场注册表(连接器源)的宿主侧数据层。
23
29
  import { readRegistry, upsertMarketplace } from "./wb-registry.js";
24
30
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)的宿主侧数据层。
25
31
  import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeReadme, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
32
+ // 社区 MCP:官方 MCP Registry(registry.modelcontextprotocol.io)只读取数,数据层在 lib/mcp-community.js。
33
+ import { wbMcpCommunityList, wbMcpCommunityCount } from "./mcp-community.js";
26
34
  // 社区元信息(维护者 / 最近提交 / issue / star):打开详情时按需查,6 小时缓存。
27
35
  import { wbRepoMeta } from "./community-meta.js";
28
36
  // npm 近 30 天下载量:插件卡片底栏的「↓N」,目录里没有这个字段,只能去 npm 统计接口取。
@@ -45,13 +53,34 @@ import {
45
53
  saveManualToken as oauthSaveManualToken,
46
54
  submitCallback as oauthSubmitCallback,
47
55
  } from "./mcp-oauth.js";
48
- import { hasTeamBridge, installExpertSkills, listExpertRecent, listExpertSkills, prepareExpertPersona, readExpertDisplayNames, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, uninstallTeamMemberPersonas, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
49
- import { attachPersona, detachPersona, listPersonas, reconcilePersonas } from "./session-persona.js";
56
+ import { attachPersona, detachPersona, installPersonaRuntime, listPersonas, reconcilePersonas } from "./session-persona.js";
50
57
  // clearTextCache 的 import 已随 toolTailor 的「清空缓存」动作一起删掉(见下方 toolTailor 注释)。
51
58
  import { scanSessionLedger } from "./tool-ledger.js";
52
59
  import { evaluateEnvExpression, jsExpressionToTemplate } from "./workspace-config.js";
53
60
  import { canonicalWorkspacePath, installAgentRuntime, listWorkspaceRecords, liveWorkspaceAgentCount, projectConnectionsView, readWorkspaceConfigCached, reconcileWorkspaceConnections, reconcileWorkspaceRestricts, reconnectWorkspaceServer, withWorkspaceWrite, workspaceConnectionStatus, workspaceMountErrorsView, workspaceRestrictErrorView, workspaceScopeErrorsView, workspaceToolSchemas, writeWorkspaceConfigFile } from "./workspace-runtime.js";
54
61
 
62
+ // ── P0-1:市场数据模块按需加载 ──────────────────────────────────────────
63
+ // wb-market(连接器 ZIP + SkillHub)与 expert-market(专家目录)合计约 68ms 的冷导入,
64
+ // 面板一次都没打开时纯属宿主启动成本。两者只被市场 / 专家相关 RPC 用到,因此改为
65
+ // 首次调用时才 import。缓存的是 Promise 本身:并发首次调用共享同一次解析,不会重复加载;
66
+ // 解析失败时清掉缓存,下次调用可重试(不会永久卡在 rejected Promise 上)。
67
+ let marketModule = null;
68
+ let expertModule = null;
69
+ const loadMarket = () => {
70
+ marketModule ??= import("./wb-market.js").catch((error) => {
71
+ marketModule = null;
72
+ throw error;
73
+ });
74
+ return marketModule;
75
+ };
76
+ const loadExpert = () => {
77
+ expertModule ??= import("./expert-market.js").catch((error) => {
78
+ expertModule = null;
79
+ throw error;
80
+ });
81
+ return expertModule;
82
+ };
83
+
55
84
  var __runInitializers = function(thisArg, initializers, value) {
56
85
  var useValue = arguments.length > 2;
57
86
  for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
@@ -180,7 +209,8 @@ async function injectStoredOAuthTokens(mcpServers) {
180
209
 
181
210
  /** 解析某连接器里某 server 的授权靶点(url + 现有 headers)。 */
182
211
  async function resolveOAuthTarget(connectorId, serverName) {
183
- const preview = await wbConnectorPreview(connectorId);
212
+ const marketApi = await loadMarket();
213
+ const preview = await marketApi.wbConnectorPreview(connectorId);
184
214
  const servers = preview?.mcpServers && typeof preview.mcpServers === "object" ? preview.mcpServers : {};
185
215
  const name = String(serverName || "").trim() || Object.keys(servers)[0] || "";
186
216
  const rawServer = servers[name];
@@ -215,6 +245,9 @@ function resolveShellService(ctx) {
215
245
  async function openExternalUrl(ctx, url) {
216
246
  const target = String(url || "").trim();
217
247
  if (!target) return { opened: false, reason: "empty-url" };
248
+ // 协议白名单在交给宿主 shell 之前先过一遍:Electron 的 openExternal 同样能打开
249
+ // file: / 本地可执行文件,不能因为「宿主有 shell」就跳过校验。
250
+ if (!isAllowedExternalUrl(target)) return { opened: false, reason: "blocked-url" };
218
251
  const shell = resolveShellService(ctx);
219
252
  if (shell && typeof shell.openExternal === "function") {
220
253
  try {
@@ -225,17 +258,10 @@ async function openExternalUrl(ctx, url) {
225
258
  }
226
259
  }
227
260
  try {
228
- const { spawn } = await import("node:child_process");
229
- if (process.platform === "win32") {
230
- // 绝不能用 `cmd /c start "$url"`:Node 只在参数含空格时才给参数加引号,而 URL 不含空格,
231
- // cmd 于是把 URL 里的 & 当成命令分隔符,授权地址被截断成 `?response_type=code`,
232
- // 服务器随后回 `invalid_request: client_id is required`。
233
- // rundll32 走 ShellExecute 且参数直传,不经 cmd 解析。
234
- spawn("rundll32", ["url.dll,FileProtocolHandler", target], { detached: true, stdio: "ignore" }).unref();
235
- } else {
236
- spawn(process.platform === "darwin" ? "open" : "xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
237
- }
238
- return { opened: true, via: "spawn" };
261
+ const opened = await openSystemUrl(target, { logger: ctx?.logger });
262
+ if (opened.ok) return { opened: true, via: "spawn" };
263
+ // headless 主机没有桌面打开器:如实告诉面板,让它把授权链接交给用户手动打开。
264
+ return { opened: false, reason: opened.reason || "system-opener-unavailable" };
239
265
  } catch (error) {
240
266
  return { opened: false, reason: String(error?.message || error) };
241
267
  }
@@ -600,6 +626,11 @@ async function checkForUpdate(now = Date.now(), force = false) {
600
626
  return result;
601
627
  }
602
628
 
629
+ // 凭据类失败(401/403 / 令牌解析失败 / OAuth 报错)与「进程起不来、网络不通」要分开说:
630
+ // 前者重连多少次都不会恢复,面板应显示「需授权」并把用户送去授权的入口。这条正则只用于
631
+ // 展示态的细分,不参与任何控制流;匹配不到就保持原本的「连接失败」。
632
+ const AUTH_FAILURE_RE = /(401|403|unauthori[sz]ed|forbidden|invalid[ _-]?token|access token|token parse|oauth|bearer|鉴权|认证|授权)/i;
633
+
603
634
  function summarize(entry, inventory, managed, conflict = false, lastLog = null) {
604
635
  const live = entry.options.config;
605
636
  const raw = managed || live;
@@ -617,6 +648,8 @@ function summarize(entry, inventory, managed, conflict = false, lastLog = null)
617
648
  row.lastError = row.status !== "failed" ? null : lastLog && (lastLog.type === "error" || lastLog.type === "warn")
618
649
  ? sanitizeMcpLog(lastLog.text)
619
650
  : "MCP 未注册任何工具:连接失败或 tools/list 同步失败";
651
+ // 401/403 这类失败重连不会恢复:面板据此把「连接失败」细分成「需授权」。
652
+ row.authRequired = row.status === "failed" && AUTH_FAILURE_RE.test(`${row.lastError || ""} ${lastLog?.text || ""}`);
620
653
  return row;
621
654
  }
622
655
  // 项目 MCP 的行状态:与全局 summarize() 走**同一套规则、同一个判定函数**(deriveMcpPhase),
@@ -668,6 +701,8 @@ function summarizeWorkspaceRow(ctx, wsPath, server, mountError) {
668
701
  row.lastError = row.lastError ? `${notice};${row.lastError}` : notice;
669
702
  }
670
703
  }
704
+ row.authRequired = row.status === "failed" && !row.mountFailed && !row.scopeFailed
705
+ && AUTH_FAILURE_RE.test(`${row.lastError || ""} ${lastLog?.text || ""}`);
671
706
  return row;
672
707
  }
673
708
  // Fingerprint the exact redacted wire projection. Keeping a manual field list here
@@ -973,8 +1008,36 @@ function workspaceServerView(server) {
973
1008
  // 2) 项目 MCP 服务器名与禁用状态(各工作区 .dsh/mcp.json,只读);
974
1009
  // 3) 当前注册表里的全部工具 Schema(ctx.tools.schemas(),全局 + 项目 + 内置)。
975
1010
  // 只读操作:不写任何配置、不碰会话目录。禁用/启用动作复用下方既有的 disable/enable。
1011
+ // P2-1:账本缓存。30 天口径在本机实测 1.7s(6542 条事件),而看板每次挂载、每次切
1012
+ // 天数都会重扫一遍;5 分钟内同 days 直接回上次结果。并发首次调用共享同一条扫描
1013
+ // (看板与 toolTailor 页可能同时挂载),避免同一份日志被解两遍。
1014
+ // days 在这里收敛到 [1,365]:缓存键就是 days,不能让 0 / 负数 / 超大值各自成键。
1015
+ const TAILOR_CACHE_TTL = 5 * 60 * 1000;
1016
+ const tailorCache = new Map(); // days -> { at, report }
1017
+ const tailorInFlight = new Map(); // days -> Promise<report>
976
1018
  async function buildToolTailorReport(ctx, payload) {
977
- const days = Math.trunc(Number(payload?.days) || 30);
1019
+ const days = Math.min(365, Math.max(1, Math.trunc(Number(payload?.days) || 30)));
1020
+ const refresh = payload?.refresh === true;
1021
+ if (!refresh) {
1022
+ const hit = tailorCache.get(days);
1023
+ if (hit && Date.now() - hit.at < TAILOR_CACHE_TTL) return hit.report;
1024
+ const pending = tailorInFlight.get(days);
1025
+ if (pending) return pending;
1026
+ }
1027
+ const task = scanToolTailor(ctx, days)
1028
+ .then((report) => {
1029
+ tailorCache.set(days, { at: Date.now(), report });
1030
+ return report;
1031
+ })
1032
+ .finally(() => {
1033
+ if (tailorInFlight.get(days) === task) tailorInFlight.delete(days);
1034
+ });
1035
+ tailorInFlight.set(days, task);
1036
+ return task;
1037
+ }
1038
+
1039
+ /** 真正干活的那次扫描(缓存旁路 / 未命中时调用)。 */
1040
+ async function scanToolTailor(ctx, days) {
978
1041
  const mcpServerNames = [];
979
1042
  const disabledServers = new Set();
980
1043
  for (const entry of allMcpEntries(ctx)) {
@@ -1030,9 +1093,21 @@ async function attachRegisteredSkills(ctx, report) {
1030
1093
  const available = registered.length > 0;
1031
1094
  const registeredNames = new Set(registered.map((skill) => skill.name));
1032
1095
  const observedNames = new Set(observed.map((skill) => skill.name));
1096
+ // 禁用/释放按钮需要的三个判定字段跟着注册表行走:path/source 决定「能不能写」
1097
+ // (白名单口径见 lib/skill-disable.js),disabled 直接读宿主解析出的 invocation 位。
1098
+ // 未注册的行(注册表缺席 / 已卸载技能的历史调用)不带这些字段——客户端不给它出按钮。
1099
+ const control = (skill) => {
1100
+ if (!skill) return {};
1101
+ return {
1102
+ disabled: skill.invocation?.modelInvocable === false,
1103
+ ...(typeof skill.path === "string" && skill.path ? { path: skill.path } : {}),
1104
+ ...(typeof skill.source === "string" && skill.source ? { source: skill.source } : {}),
1105
+ };
1106
+ };
1107
+ const registeredByName = new Map(registered.map((skill) => [skill.name, skill]));
1033
1108
  const merged = available
1034
1109
  ? [
1035
- ...observed.map((skill) => ({ ...skill, registered: registeredNames.has(skill.name) })),
1110
+ ...observed.map((skill) => ({ ...skill, registered: registeredNames.has(skill.name), ...control(registeredByName.get(skill.name)) })),
1036
1111
  ...registered
1037
1112
  .filter((skill) => !observedNames.has(skill.name))
1038
1113
  .map((skill) => ({
@@ -1043,6 +1118,7 @@ async function attachRegisteredSkills(ctx, report) {
1043
1118
  firstUsedMs: null,
1044
1119
  lastUsedMs: null,
1045
1120
  registered: true,
1121
+ ...control(skill),
1046
1122
  })),
1047
1123
  ].sort((a, b) => (b.calls - a.calls) || String(a.name).localeCompare(String(b.name)))
1048
1124
  : observed.map((skill) => ({ ...skill, registered: null }));
@@ -1178,10 +1254,11 @@ async function collectLoadoutItems(ctx) {
1178
1254
  // 3) 专家:profile 补丁里的召唤预设 + 目录缓存补展示名 / 更新时间。
1179
1255
  try {
1180
1256
  const patch = await readPatch(ctx);
1181
- const presets = readExpertPresets(patch.content);
1257
+ const expertApi = await loadExpert();
1258
+ const presets = expertApi.readExpertPresets(patch.content);
1182
1259
  let experts = [];
1183
1260
  try {
1184
- const catalog = await wbExpertCatalog();
1261
+ const catalog = await expertApi.wbExpertCatalog();
1185
1262
  experts = Array.isArray(catalog?.experts) ? catalog.experts : [];
1186
1263
  } catch {
1187
1264
  // 目录拉不到就退回 plugin id 作名称。
@@ -1224,7 +1301,7 @@ async function collectLoadoutItems(ctx) {
1224
1301
  let McpManagerGateway = (() => {
1225
1302
  let _classSuper = TypertRemoteService;
1226
1303
  let _instanceExtraInitializers = [];
1227
- let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorIcon_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeReadme_decorators, _wbAwesomeUpdateCheck_decorators, _wbRepoMeta_decorators, _wbNpmDownloads_decorators, _wbLoadoutExport_decorators, _wbLoadoutDiff_decorators;
1304
+ let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorIcon_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthRefresh_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeReadme_decorators, _wbAwesomeUpdateCheck_decorators, _wbRepoMeta_decorators, _wbNpmDownloads_decorators, _wbLoadoutExport_decorators, _wbLoadoutDiff_decorators;
1228
1305
  return class McpManagerGateway extends _classSuper {
1229
1306
  static {
1230
1307
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -1306,6 +1383,8 @@ let McpManagerGateway = (() => {
1306
1383
  __esDecorate(this, null, _wbOAuthToken_decorators, { kind: "method", name: "wbOAuthToken", static: false, private: false, access: { has: (obj) => "wbOAuthToken" in obj, get: (obj) => obj.wbOAuthToken }, metadata: _metadata }, null, _instanceExtraInitializers);
1307
1384
  _wbOAuthOverview_decorators = [Remote("wbOAuthOverview")];
1308
1385
  __esDecorate(this, null, _wbOAuthOverview_decorators, { kind: "method", name: "wbOAuthOverview", static: false, private: false, access: { has: (obj) => "wbOAuthOverview" in obj, get: (obj) => obj.wbOAuthOverview }, metadata: _metadata }, null, _instanceExtraInitializers);
1386
+ _wbOAuthRefresh_decorators = [Remote("wbOAuthRefresh")];
1387
+ __esDecorate(this, null, _wbOAuthRefresh_decorators, { kind: "method", name: "wbOAuthRefresh", static: false, private: false, access: { has: (obj) => "wbOAuthRefresh" in obj, get: (obj) => obj.wbOAuthRefresh }, metadata: _metadata }, null, _instanceExtraInitializers);
1309
1388
  _wbOAuthOpen_decorators = [Remote("wbOAuthOpen")];
1310
1389
  __esDecorate(this, null, _wbOAuthOpen_decorators, { kind: "method", name: "wbOAuthOpen", static: false, private: false, access: { has: (obj) => "wbOAuthOpen" in obj, get: (obj) => obj.wbOAuthOpen }, metadata: _metadata }, null, _instanceExtraInitializers);
1311
1390
  _wbSkillCatalog_decorators = [Remote("wbSkillCatalog")];
@@ -1354,6 +1433,13 @@ let McpManagerGateway = (() => {
1354
1433
  super(ctx, "mcpManager");
1355
1434
  ensureLogCapture(ctx);
1356
1435
  installAgentRuntime(ctx);
1436
+ // 会话级专家「记住选择」:agent 重建(切会话/重启/压缩)后自动补回人设。
1437
+ // 与项目 MCP 的安装完全独立;失败只能降级(不会自动恢复),不能拖垮插件装配。
1438
+ try {
1439
+ installPersonaRuntime(ctx);
1440
+ } catch (error) {
1441
+ ctx.logger?.warn?.(`armory: 会话专家运行时安装失败:${error?.message || error}`);
1442
+ }
1357
1443
  __runInitializers(this, _instanceExtraInitializers);
1358
1444
  }
1359
1445
  async list() {
@@ -1430,7 +1516,7 @@ let McpManagerGateway = (() => {
1430
1516
  const state = await managedState(this.ctx);
1431
1517
  if (hasExternalMcpName(this.ctx, state, normalized.name) || state.byName.has(normalized.name)) throw new Error("MCP already exists: " + normalized.name);
1432
1518
  await writePatch(state, updateManagedMcpPatch(state.content, [normalized], { replace: false, reservedIds: reservedEntryIds(this.ctx) }));
1433
- return { note: "已添加到 profile patch,正在热加载" };
1519
+ return { note: "已添加到 profile patch,正在热加载" + describeLaunchRewrite(spec?.command, normalized.command) };
1434
1520
  });
1435
1521
  }
1436
1522
  async update(spec) {
@@ -1442,7 +1528,7 @@ let McpManagerGateway = (() => {
1442
1528
  const normalized = normalizeOne(restoreRedactedSpec(spec, current));
1443
1529
  if (!Object.hasOwn(normalized, "disabled") && current.disabled) normalized.disabled = true;
1444
1530
  await writePatch(state, updateManagedMcpPatch(state.content, [normalized], { replace: false, reservedIds: reservedEntryIds(this.ctx, new Set([state.entryIds.get(normalized.name)].filter(Boolean))) }));
1445
- return { note: "已更新 profile patch,正在热加载" };
1531
+ return { note: "已更新 profile patch,正在热加载" + describeLaunchRewrite(spec?.command, normalized.command) };
1446
1532
  });
1447
1533
  }
1448
1534
  async removeServer(name) {
@@ -1521,6 +1607,7 @@ let McpManagerGateway = (() => {
1521
1607
  async toolTailor(payload) {
1522
1608
  // 只读账本:扫描本机会话日志 + 当前注册表 Schema,见 tool-ledger.js。
1523
1609
  // days 由调用方传(7/30/90),缺省 30,服务端钳到 [1,365]。
1610
+ // 5 分钟缓存命中直接回上次报告;用户点「重扫」时传 refresh:true 强制重算。
1524
1611
  // 「清空解压文本缓存」这个可选动作已删(2026-10-23):它曾声称「先清后扫,面板立即
1525
1612
  // 看到占用归零」,实测恰好相反 —— 快照取在扫描之后,同一发调用里日志被重新解压回填,
1526
1613
  // 前后都是 32 条 / 65.8 MB;而 clearTextCache() 的返回值(clearedBytes)压根没进报告
@@ -1791,15 +1878,40 @@ let McpManagerGateway = (() => {
1791
1878
  return { skill: result };
1792
1879
  }
1793
1880
  async revealSkill(payload) {
1794
- const path = String(payload?.path || "");
1795
- if (!path) throw new Error("该 skill 没有可打开的文件路径(运行时注册的 skill 不在磁盘上)");
1881
+ // 路径只信宿主 skill 注册表的解析结果,不信客户端传来的 payload.path:
1882
+ // 否则面板能借「打开目录」把任意路径交给 explorer / open 定位。
1883
+ const name = String(payload?.name || "").trim();
1884
+ if (!name) throw new Error("缺少 skill 名称");
1885
+ const resolved = await withSkillScope(this.ctx, payload, async (registry, options) => {
1886
+ if (typeof registry.get !== "function") throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法定位 skill");
1887
+ const skill = await registry.get(name, options);
1888
+ if (!skill) throw new Error("没有找到这个 skill(可能刚被移动或删除):" + name);
1889
+ return String(skill.path || "");
1890
+ });
1891
+ if (resolved === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法定位 skill");
1892
+ const path = String(resolved);
1893
+ if (!path || path.includes("\0") || !isAbsolute(path)) throw new Error("该 skill 没有可打开的文件路径(运行时注册的 skill 不在磁盘上)");
1796
1894
  const shell = typeof this.ctx.get === "function" ? this.ctx.get("shell") : undefined;
1797
- if (shell && typeof shell.showItemInFolder === "function") { shell.showItemInFolder(path); return { note: "已在资源管理器中定位" }; }
1798
- if (shell && typeof shell.openExternal === "function") { shell.openExternal(path); return { note: "已交给宿主打开" }; }
1799
- const { spawn } = await import("node:child_process");
1800
- if (process.platform === "win32") spawn("explorer", ["/select,", path], { detached: true, stdio: "ignore" }).unref();
1801
- else spawn(process.platform === "darwin" ? "open" : "xdg-open", [path], { detached: true, stdio: "ignore" }).unref();
1802
- return { note: "已交给系统打开" };
1895
+ if (shell && typeof shell.showItemInFolder === "function") {
1896
+ try {
1897
+ await shell.showItemInFolder(path);
1898
+ return { note: "已在资源管理器中定位" };
1899
+ } catch {
1900
+ /* 宿主定位失败时落到系统兜底 */
1901
+ }
1902
+ }
1903
+ if (shell && typeof shell.openExternal === "function") {
1904
+ try {
1905
+ await shell.openExternal(path);
1906
+ return { note: "已交给宿主打开" };
1907
+ } catch {
1908
+ /* 宿主打开失败时落到系统兜底 */
1909
+ }
1910
+ }
1911
+ const opened = await revealSystemPath(path, { logger: this.ctx?.logger });
1912
+ if (opened.ok) return { note: "已交给系统打开" };
1913
+ // headless 主机没有桌面文件管理器:不能谎报「已打开」,把真实路径交回去。
1914
+ return { note: `没有可用的系统打开器(${opened.reason}),路径:${path}`, path, opened: false };
1803
1915
  }
1804
1916
  // 卸载一条已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。
1805
1917
  // 只信注册表给的 source/path(客户端传的路径不参与判断);bundled / runtime / custom
@@ -1817,6 +1929,36 @@ let McpManagerGateway = (() => {
1817
1929
  if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
1818
1930
  return { note: `已卸载 ${name}(${result.kind === "symlink" ? "符号链接" : "目录"}:${result.path})` };
1819
1931
  }
1932
+ // 禁用 / 释放一条 skill(工具裁缝技能账本的行内按钮):写 SKILL.md frontmatter 的
1933
+ // disable-model-invocation 标志——宿主 skill-filesystem 官方支持的「模型侧禁用」键,
1934
+ // 根目录监听器会自动刷新模型目录(无需重启)。禁用后技能不再进模型目录、不再缴
1935
+ // 上下文税;输入框手动 /引用 不受影响。写盘规则与安全闸门全在 lib/skill-disable.js
1936
+ // (来源白名单 / 根目录包含性 / realpath 防链接出根),与 removeSkill 同一套纪律。
1937
+ async disableSkill(payload) {
1938
+ return this.setSkillDisabled(payload, true);
1939
+ }
1940
+ async enableSkill(payload) {
1941
+ return this.setSkillDisabled(payload, false);
1942
+ }
1943
+ async setSkillDisabled(payload, disabled) {
1944
+ const name = String(payload?.name || "").trim();
1945
+ if (!name) throw new Error("缺少 skill 名称");
1946
+ const result = await withSkillScope(this.ctx, payload, async (registry, options) => {
1947
+ if (typeof registry.get !== "function") throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法修改");
1948
+ const skill = await registry.get(name, options);
1949
+ if (!skill) throw new Error("没有找到这个 skill(可能刚被移动或删除):" + name);
1950
+ return setSkillModelInvocationOnDisk(skill, { disabled, cwd: options.cwd, home: homedir() });
1951
+ });
1952
+ if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法修改");
1953
+ // 幂等重放(changed=false)也照常报成功:按钮态以注册表实况为准,重放只是补一次确认。
1954
+ return {
1955
+ note: disabled
1956
+ ? `已禁用 ${name}(模型不再加载,手动引用不受影响)`
1957
+ : `已释放 ${name}(模型可重新调用)`,
1958
+ path: result.path,
1959
+ changed: result.changed,
1960
+ };
1961
+ }
1820
1962
  // 安装时间 + 市场原名(按 skill 名字索引):宿主自扫标准技能根目录、读 SKILL.md
1821
1963
  // 头部 name 建立映射——不依赖 listSkills 行里的 path(注册表可能整列不给)。
1822
1964
  // 永不抛错——时间与原名是辅助信息,单个目录读失败只是没有该条目,不该拖垮看板。
@@ -1847,18 +1989,22 @@ let McpManagerGateway = (() => {
1847
1989
  // 连接器安装复用 applyManagedImport / applyWorkspaceImport:准入、同名跳过、entry id
1848
1990
  // 预留、项目对齐全部与粘贴导入同一条链路,市场只是多了一个「JSON 从哪来」的来源。
1849
1991
  async wbConnectorCatalog() {
1850
- return wbConnectorCatalog();
1992
+ const marketApi = await loadMarket();
1993
+ return marketApi.wbConnectorCatalog();
1851
1994
  }
1852
1995
  // 顶栏「市场」chip 的常驻计数:只读磁盘缓存,绝不联网(无缓存返回 null)。
1853
1996
  async wbConnectorCachedCount() {
1854
- return wbConnectorCachedCount();
1997
+ const marketApi = await loadMarket();
1998
+ return marketApi.wbConnectorCachedCount();
1855
1999
  }
1856
2000
  async wbConnectorPreview(payload) {
1857
- return wbConnectorPreview(payload?.connectorId);
2001
+ const marketApi = await loadMarket();
2002
+ return marketApi.wbConnectorPreview(payload?.connectorId);
1858
2003
  }
1859
2004
  // 某条连接器的图标(data URL)。市场卡片进视口才调,payload 形状同 wbConnectorPreview。
1860
2005
  async wbConnectorIcon(payload) {
1861
- return wbConnectorIcon(payload?.connectorId);
2006
+ const marketApi = await loadMarket();
2007
+ return marketApi.wbConnectorIcon(payload?.connectorId);
1862
2008
  }
1863
2009
  async wbConnectorInstall(payload) {
1864
2010
  const connectorId = String(payload?.connectorId || "").trim();
@@ -1866,17 +2012,35 @@ let McpManagerGateway = (() => {
1866
2012
  const mode = payload?.mode === "replace" ? "replace" : "merge";
1867
2013
  const wsPath = payload?.wsPath ? canonicalWorkspacePath(String(payload.wsPath)) : "";
1868
2014
  const tokens = payload?.tokens && typeof payload.tokens === "object" ? payload.tokens : {};
1869
- const preview = await wbConnectorPreview(connectorId);
2015
+ const marketApi = await loadMarket();
2016
+ const preview = await marketApi.wbConnectorPreview(connectorId);
1870
2017
  // T04/R7:把面板收集到的 token 填进 ${...} 占位符。只填用户明确提供的槽位,
1871
2018
  // 未填的保持原样并把槽位名回传面板(missingSlots)——绝不代填、绝不猜值。
1872
2019
  const { mcpServers, missingSlots } = applyTokens(preview.mcpServers, tokens);
1873
2020
  // R8:先把已授权的 OAuth token 注进 headers 再探测——否则授权过的连接器每次重装
1874
2021
  // 都会被判成「需要授权」,用户得反复点授权。
1875
2022
  const authorized = await injectStoredOAuthTokens(mcpServers);
2023
+ // 本机固定依赖预检:本地客户端型连接器(如 nvapp-windows-local)写死一个本机可执行文件。
2024
+ // 缺这个文件时写进配置只会让宿主重连 10 次后注销工具(issue #3),所以安装前直接拦住,
2025
+ // 并把「缺哪个路径 / 平台是否支持」作为结构化字段回传面板做本地化提示。
2026
+ const localDeps = await marketApi.connectorLocalDeps(authorized);
2027
+ const localMissing = localDeps.filter((dep) => dep.status === "missing").map((dep) => dep.path);
2028
+ const localUnsupported = localDeps.filter((dep) => dep.status === "platform").map((dep) => dep.path);
2029
+ if (localMissing.length || localUnsupported.length) {
2030
+ return {
2031
+ ok: false,
2032
+ connectorId,
2033
+ missingSlots,
2034
+ localDeps,
2035
+ localMissing,
2036
+ localUnsupported,
2037
+ error: `本机缺少连接器依赖:${[...localMissing, ...localUnsupported].join("、")}`,
2038
+ };
2039
+ }
1876
2040
  // 装前 401 探测:索引 auth_mode 会漏标鉴权(tec-do 裸 URL 装完必 401)。断言从
1877
2041
  // 「任一不就整体中止」改为「回传需授权清单」:面板据此弹浏览器授权页,授权完成
1878
2042
  // 后重装即可,绝不再抛「DSH 无法自动注入」把用户挡在门外。
1879
- const probe = await probeMcpServersAuthorization(authorized);
2043
+ const probe = await marketApi.probeMcpServersAuthorization(authorized);
1880
2044
  if (probe?.needsAuth?.length) {
1881
2045
  return {
1882
2046
  ok: false,
@@ -1890,8 +2054,27 @@ let McpManagerGateway = (() => {
1890
2054
  const result = wsPath
1891
2055
  ? await applyWorkspaceImport(this.ctx, wsPath, admittedImport(json), mode)
1892
2056
  : await applyManagedImport(this.ctx, admittedImport(json), mode);
1893
- // T04/R8:装成功后登记本地状态(供「已安装」全量筛选与来源锁)。登记失败只记
1894
- // 日志、绝不影响安装结果——状态文件坏了不该让用户白装一次。
2057
+ // T04/R8:只有**真的写进了 server** 才登记「已安装」。同名条目被其他配置层占用时,
2058
+ // applyManagedImport 会跳过它并返回空 added/updated——早前这里无条件登记,于是一条
2059
+ // 根本没落盘的连接器也带着「已安装」徽标(用户报的「显示装好了却用不了」就是这个
2060
+ // 徽标在说谎)。跳过 ≠ 装好:这里如实回失败,并把跳过原因带回面板。
2061
+ const wrote = (Array.isArray(result?.added) ? result.added.length : 0)
2062
+ + (Array.isArray(result?.updated) ? result.updated.length : 0);
2063
+ if (!wrote) {
2064
+ const skipped = Array.isArray(result?.warnings) ? result.warnings.filter(Boolean) : [];
2065
+ return {
2066
+ ok: false,
2067
+ connectorId,
2068
+ missingSlots,
2069
+ localDeps,
2070
+ wrote: 0,
2071
+ skipped,
2072
+ error: skipped.length
2073
+ ? `没有写入任何 MCP 配置:${skipped.join(";")}`
2074
+ : "没有写入任何 MCP 配置:目标作用域里没有可写入的 server",
2075
+ };
2076
+ }
2077
+ // 登记失败只记日志、绝不影响安装结果——状态文件坏了不该让用户白装一次。
1895
2078
  try {
1896
2079
  await recordInstallState({
1897
2080
  kind: "connector",
@@ -1904,7 +2087,7 @@ let McpManagerGateway = (() => {
1904
2087
  } catch (error) {
1905
2088
  this.ctx.logger?.warn?.(`wb-market: 连接器 ${connectorId} 安装状态登记失败(不影响安装结果):${error?.message || error}`);
1906
2089
  }
1907
- return { ...result, missingSlots };
2090
+ return { ...result, missingSlots, localDeps, wrote };
1908
2091
  }
1909
2092
  // ── R8:连接器 MCP 的浏览器授权(RFC 9728 / 8414 / 7591 / 7636)──────────
1910
2093
  // 面板只负责展示与点击:安装前探测到 401 → 这里拿到 authorizeUrl 交给系统浏览器,
@@ -1986,20 +2169,68 @@ let McpManagerGateway = (() => {
1986
2169
  const overview = await oauthOverview(target.url).catch(() => null);
1987
2170
  return { ok: true, connectorId, serverName: target.serverName, ...(overview || {}) };
1988
2171
  }
2172
+ // R8/P0:强制刷新已存的 OAuth token(有 refresh_token 才刷得动)。面板的「刷新授权」
2173
+ // 走这里,再调用一次 wbConnectorInstall 把新凭据写回 headers——mcp-client 只在建立连接时
2174
+ // 快照 headers,光刷令牌库不改配置等于没刷。
2175
+ // 刷不动(没有 refresh_token / 已被服务端撤销)时回 needsAuth:true,让面板回到浏览器授权,
2176
+ // 而不是给一条「刷新失败」的死胡同。
2177
+ async wbOAuthRefresh(payload) {
2178
+ const connectorId = String(payload?.connectorId || "").trim();
2179
+ if (!connectorId) throw new Error("缺少 connectorId");
2180
+ const target = await resolveOAuthTarget(connectorId, payload?.serverName);
2181
+ const before = await oauthOverview(target.url).catch(() => null);
2182
+ if (!before?.authorized) {
2183
+ return {
2184
+ ok: false,
2185
+ connectorId,
2186
+ serverName: target.serverName,
2187
+ authorized: false,
2188
+ needsAuth: true,
2189
+ error: "该连接器还没有授权记录,请先完成授权",
2190
+ };
2191
+ }
2192
+ const ensured = await oauthEnsureToken(target.url, { force: true })
2193
+ .catch((error) => ({ ok: false, error: error?.message || String(error) }));
2194
+ const after = await oauthOverview(target.url).catch(() => null);
2195
+ if (!ensured?.ok) {
2196
+ return {
2197
+ ok: false,
2198
+ connectorId,
2199
+ serverName: target.serverName,
2200
+ authorized: Boolean(after?.authorized),
2201
+ needsAuth: true,
2202
+ refreshable: Boolean(after?.refreshable),
2203
+ expiresAt: after?.expiresAt || "",
2204
+ error: ensured?.error || "刷新授权失败",
2205
+ };
2206
+ }
2207
+ return {
2208
+ ok: true,
2209
+ connectorId,
2210
+ serverName: target.serverName,
2211
+ authorized: true,
2212
+ refreshed: ensured.refreshed === true,
2213
+ expiresAt: after?.expiresAt || "",
2214
+ refreshable: Boolean(after?.refreshable),
2215
+ };
2216
+ }
1989
2217
  async wbOAuthOpen(payload) {
1990
2218
  const opened = await openExternalUrl(this.ctx, payload?.url);
1991
2219
  return { ok: opened.opened === true, ...opened };
1992
2220
  }
1993
2221
  async wbSkillCatalog(payload) {
1994
- return wbSkillCatalog(payload || {});
2222
+ const marketApi = await loadMarket();
2223
+ return marketApi.wbSkillCatalog(payload || {});
1995
2224
  }
1996
2225
  async wbSkillDetail(payload) {
1997
- return wbSkillDetail(payload || {});
2226
+ const marketApi = await loadMarket();
2227
+ return marketApi.wbSkillDetail(payload || {});
1998
2228
  }
1999
2229
  async wbSkillInstall(payload) {
2230
+ const marketApi = await loadMarket();
2000
2231
  // 安装进度经 ctx.logger 落到宿主日志(Remote 单次调用没有流式回传通道,面板按
2001
2232
  // 阶段提示「正在下载与校验」,完成后一次拿到结果与 note)。
2002
- const result = await wbSkillInstall(payload || {}, {
2233
+ const result = await marketApi.wbSkillInstall(payload || {}, {
2003
2234
  onProgress: (progress) => this.ctx.logger?.debug?.(`wb-market: ${progress.phase} ${progress.index}/${progress.total}`),
2004
2235
  });
2005
2236
  // T04/R8:装成功后登记本地状态。key 取不到就跳过(record 对空 key 会抛错)。
@@ -2021,10 +2252,12 @@ let McpManagerGateway = (() => {
2021
2252
  return result;
2022
2253
  }
2023
2254
  async wbSkillUninstall(payload) {
2024
- return wbSkillUninstall(payload || {});
2255
+ const marketApi = await loadMarket();
2256
+ return marketApi.wbSkillUninstall(payload || {});
2025
2257
  }
2026
2258
  async wbSkillInstalled() {
2027
- return wbSkillInstalled();
2259
+ const marketApi = await loadMarket();
2260
+ return marketApi.wbSkillInstalled();
2028
2261
  }
2029
2262
  // ── T03/R4:多市场注册表 ────────────────────────────────────────────
2030
2263
  // 数据层在 lib/wb-registry.js(T01);这里只做 RPC 转发。读 = 合并投影(内置默认
@@ -2068,6 +2301,18 @@ let McpManagerGateway = (() => {
2068
2301
  async wbAwesomeUpdateCheck() {
2069
2302
  return wbAwesomeUpdateCheck();
2070
2303
  }
2304
+ // ── 社区 MCP(官方 MCP Registry v0,只读)──────────────────────────
2305
+ // 数据层在 lib/mcp-community.js;这里只做 RPC 转发。只 GET:不写盘、不执行命令、
2306
+ // 不落任何凭据。payload {search, cursor, limit, takenNames},形状由数据层校验。
2307
+ async wbMcpCommunityList(payload) {
2308
+ return wbMcpCommunityList(payload || {});
2309
+ }
2310
+ // 社区目录全库总数的有界统计:payload {cursor, pages},返回 {ok, counted, nextCursor, done, pages}。
2311
+ // 官方 Registry 不给总数,只能顺游标数,所以这里一次最多 12 页;进度由面板侧存 localStorage、
2312
+ // 跨会话续爬。同样只 GET:不写盘、不执行命令、不落凭据、不进任何缓存。
2313
+ async wbMcpCommunityCount(payload) {
2314
+ return wbMcpCommunityCount(payload || {});
2315
+ }
2071
2316
  // ── 社区元信息:仓库「人味」信号(维护者 / 最近提交 / issue / star)────────
2072
2317
  // 详情弹层按需调用,只读公开接口,6 小时缓存;不接收也不保存任何凭据。
2073
2318
  async wbRepoMeta(payload) {
@@ -2109,19 +2354,26 @@ let McpManagerGateway = (() => {
2109
2354
  const plugin = String(payload?.plugin || "").trim();
2110
2355
  if (!sessionId) throw new Error("缺少 sessionId");
2111
2356
  if (!plugin) throw new Error("缺少 plugin");
2112
- const detail = await wbExpertDetail({ plugin });
2357
+ const expertApi = await loadExpert();
2358
+ const detail = await expertApi.wbExpertDetail({ plugin });
2113
2359
  // 团队型:成员人设先落盘,主理人人设末尾追加 DSH 桥接段(与召唤预设同一套)。
2114
- const prepared = await prepareExpertPersona(detail, {
2360
+ const prepared = await expertApi.prepareExpertPersona(detail, {
2115
2361
  onLog: (message) => this.ctx.logger?.debug?.(`wb-expert: ${message}`),
2116
2362
  });
2117
2363
  const result = attachPersona(this.ctx, sessionId, {
2118
2364
  plugin,
2119
2365
  name: detail.name,
2120
2366
  personaText: prepared.personaText,
2367
+ }, {
2368
+ // 替身人设不写进 session-persona 的粘性选择:它只是原文缺失时的占位,
2369
+ // 落盘后即使上游补回原文也会一直沿用。重启后重新召唤即可拿到最新正文。
2370
+ persist: detail.personaSource !== "generated",
2121
2371
  });
2122
- await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: `session-${plugin}` }).catch(() => {});
2372
+ await expertApi.recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: `session-${plugin}` }).catch(() => {});
2123
2373
  return {
2124
2374
  ...result,
2375
+ personaSource: detail.personaSource,
2376
+ personaNotice: detail.personaNotice,
2125
2377
  profession: detail.profession,
2126
2378
  expertType: detail.expertType,
2127
2379
  members: detail.members || [],
@@ -2138,31 +2390,34 @@ let McpManagerGateway = (() => {
2138
2390
  return { sessions: listPersonas() };
2139
2391
  }
2140
2392
  async wbExpertCatalog() {
2141
- return wbExpertCatalog();
2393
+ const expertApi = await loadExpert();
2394
+ return expertApi.wbExpertCatalog();
2142
2395
  }
2143
2396
  async wbExpertDetail(payload) {
2144
- return wbExpertDetail(payload || {});
2397
+ const expertApi = await loadExpert();
2398
+ return expertApi.wbExpertDetail(payload || {});
2145
2399
  }
2146
2400
  async wbExpertSummon(payload) {
2147
2401
  const plugin = String(payload?.plugin || "").trim();
2148
2402
  if (!plugin) throw new Error("缺少 plugin");
2403
+ const expertApi = await loadExpert();
2149
2404
  // 详情即预览:召唤写盘前拉一次真实人设与技能清单,失败则整体不动。
2150
- const detail = await wbExpertDetail({ plugin });
2405
+ const detail = await expertApi.wbExpertDetail({ plugin });
2151
2406
  const withSkills = payload?.withSkills !== false && detail.skills.length > 0;
2152
2407
  // 团队型:成员人设先落盘(人设不进 preset,避免每轮白付),主理人人设追加桥接段。
2153
- const prepared = await prepareExpertPersona(detail, {
2408
+ const prepared = await expertApi.prepareExpertPersona(detail, {
2154
2409
  onLog: (message) => this.ctx.logger?.debug?.(`wb-expert: ${message}`),
2155
2410
  });
2156
2411
  // 技能先落盘(失败只影响技能,不阻断召唤),预设行再走唯一写链(文件锁 + 版本校验 + 原子写)。
2157
2412
  let skillResult = { installed: [], notes: [] };
2158
2413
  if (withSkills) {
2159
- skillResult = await installExpertSkills(plugin, detail.skills.map((skill) => skill.name), {
2414
+ skillResult = await expertApi.installExpertSkills(plugin, detail.skills.map((skill) => skill.name), {
2160
2415
  onLog: (message) => this.ctx.logger?.debug?.(`wb-expert: ${message}`),
2161
2416
  });
2162
2417
  }
2163
2418
  const result = await withPatchWrite(this.ctx, async () => {
2164
2419
  const state = await managedState(this.ctx);
2165
- const written = writeExpertPreset(state.content, {
2420
+ const written = expertApi.writeExpertPreset(state.content, {
2166
2421
  plugin,
2167
2422
  displayName: detail.name,
2168
2423
  profession: detail.profession,
@@ -2174,11 +2429,11 @@ let McpManagerGateway = (() => {
2174
2429
  return { mode: written.mode, presetId: written.presetId };
2175
2430
  });
2176
2431
  // 召唤成功即记入最近使用(送还由 listExpertRecent 对账剔除)。记录失败不阻断召唤。
2177
- await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: result.presetId }).catch(() => {});
2432
+ await expertApi.recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: result.presetId }).catch(() => {});
2178
2433
  const teamMembers = prepared.memberResult?.installed || [];
2179
2434
  // upsert 后核对人设正文是否真带桥接段(团队型);不带则提示更新结果不完整。
2180
2435
  // 正常路径 prepared.personaText 已拼桥接段,这个分支只在异常数据时触发。
2181
- const staleBridge = detail.expertType === "team" && !hasTeamBridge(prepared.personaText);
2436
+ const staleBridge = detail.expertType === "team" && !expertApi.hasTeamBridge(prepared.personaText);
2182
2437
  return {
2183
2438
  presetId: result.presetId,
2184
2439
  presetMode: result.mode,
@@ -2200,9 +2455,10 @@ let McpManagerGateway = (() => {
2200
2455
  async wbExpertDismiss(payload) {
2201
2456
  const plugin = String(payload?.plugin || "").trim();
2202
2457
  if (!plugin) throw new Error("缺少 plugin");
2458
+ const expertApi = await loadExpert();
2203
2459
  const result = await withPatchWrite(this.ctx, async () => {
2204
2460
  const state = await managedState(this.ctx);
2205
- const content = removeExpertPreset(state.content, plugin);
2461
+ const content = expertApi.removeExpertPreset(state.content, plugin);
2206
2462
  if (content === null) return { mode: "absent" };
2207
2463
  await writePatch(state, content);
2208
2464
  return { mode: "removed" };
@@ -2210,14 +2466,14 @@ let McpManagerGateway = (() => {
2210
2466
  // 捆绑技能一并清掉(该 plugin 前缀下的全部目录;失败不回滚预设摘除——写是既成事实)。
2211
2467
  let removedSkills = [];
2212
2468
  try {
2213
- removedSkills = await uninstallExpertSkills(plugin);
2469
+ removedSkills = await expertApi.uninstallExpertSkills(plugin);
2214
2470
  } catch (error) {
2215
2471
  this.ctx.logger?.warn?.(`wb-expert: 预设已摘除,但技能清理失败:${error?.message ?? error}`);
2216
2472
  }
2217
2473
  // 团队型:成员人设目录同样按 plugin 隔离,一并清除。
2218
2474
  let removedTeam = null;
2219
2475
  try {
2220
- removedTeam = await uninstallTeamMemberPersonas(plugin);
2476
+ removedTeam = await expertApi.uninstallTeamMemberPersonas(plugin);
2221
2477
  } catch (error) {
2222
2478
  this.ctx.logger?.warn?.(`wb-expert: 预设已摘除,但成员人设清理失败:${error?.message ?? error}`);
2223
2479
  }
@@ -2238,7 +2494,8 @@ let McpManagerGateway = (() => {
2238
2494
  // 不存在的 TeamCreate/Agent(成员开不出来)。标记桥接判定放前端(需要市场目录的
2239
2495
  // expertType),这里只把 personaText 原样带出去。
2240
2496
  const patch = await readPatch(this.ctx);
2241
- const presets = readExpertPresets(patch.content);
2497
+ const expertApi = await loadExpert();
2498
+ const presets = expertApi.readExpertPresets(patch.content);
2242
2499
  // patch 只记 plugin / presetId,中英显示名在专家目录缓存里。这里补上两个字段,
2243
2500
  // 客户端按宿主语言挑:中文名给中文界面,英文名给英文界面。读缓存失败只回落
2244
2501
  // plugin 名,不影响「已召唤 / 已装技能」本身的状态判断。
@@ -2247,7 +2504,7 @@ let McpManagerGateway = (() => {
2247
2504
  // 所以目录没落盘 / 专家已下架时菜单仍能出「吴八哥」,而不是 senior-developer。
2248
2505
  let displayNames = new Map();
2249
2506
  try {
2250
- displayNames = await readExpertDisplayNames();
2507
+ displayNames = await expertApi.readExpertDisplayNames();
2251
2508
  } catch (error) {
2252
2509
  this.ctx.logger?.warn?.(`wb-expert: 读取专家显示名失败:${error?.message ?? error}`);
2253
2510
  }
@@ -2261,18 +2518,20 @@ let McpManagerGateway = (() => {
2261
2518
  nameEn: info.nameEn || row.displayName || "",
2262
2519
  };
2263
2520
  }),
2264
- skills: await listExpertSkills(),
2521
+ skills: await expertApi.listExpertSkills(),
2265
2522
  };
2266
2523
  }
2267
2524
  // 最近使用记录(输入框召唤菜单的数据源):召唤/送还时自动记录专家;技能与 MCP 由
2268
2525
  // 客户端在用户从菜单里显式选用时上报(recordUse)。只存展示所需最小字段。
2269
2526
  async wbExpertRecent() {
2270
- return listExpertRecent();
2527
+ const expertApi = await loadExpert();
2528
+ return expertApi.listExpertRecent();
2271
2529
  }
2272
2530
  async wbExpertRecordUse(payload) {
2273
2531
  const kind = String(payload?.kind || "").trim();
2274
2532
  const row = payload?.row && typeof payload.row === "object" ? payload.row : null;
2275
- return recordExpertRecent(kind, row);
2533
+ const expertApi = await loadExpert();
2534
+ return expertApi.recordExpertRecent(kind, row);
2276
2535
  }
2277
2536
  };
2278
2537
  })();