From 831e3de8d87db385f1e28f3655b47acb8ce0969f Mon Sep 17 00:00:00 2001 From: ypshidifu Date: Wed, 23 Sep 2026 12:05:16 +0800 Subject: [PATCH] =?UTF-8?q?MCP=20=E9=85=8D=E7=BD=AE=E5=AF=B9=E8=B0=83?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E6=96=B9=E6=A1=88=EF=BC=9A=E7=83=AD=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20+=20reload=20=E7=83=AD=E6=9B=B4=E6=96=B0=20+=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=85=A8=E9=9D=A2=E4=B8=AD=E6=96=87=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 mcpcfg.txt 三行配置(模式/含MCP路径/安静路径),小白只改 txt - 模式语义:自动=启动归位+命令可用+server插件可切换;手动=仅 /mcpon /mcpoff;关闭=全部禁用 - /mcpon /mcpoff:对调两份配置后调用 location.reload 热重载,MCP 立即生效 - auto 模式启动自动把含 MCP 的活配置归位为安静版(6 秒后重载),保持零 toast 零报错 - enableMcp 加门控:活配置不含 mcp 时跳过 90s 重试 connect,避免 404 空转 - 全部命令标题/描述/dialog/toast 改为简体中文 - .gitignore 排除 ai-mcp/ 与 wsl-round-mcp/ 两个独立子仓库 --- .gitignore | 13 +- tui-plugins/oc-session-all.js | 292 +++++++++++++++++++++++++++------- 2 files changed, 248 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 3e6339f..9a5a5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,15 @@ node_modules/ !tui-plugins/oc-session-all-debug.js !tui-plugins/oc-session-all.js src/ -node_modules/ \ No newline at end of file + +# 独立 git 仓库(分支名 = 文件夹名),各自独立推送到共用远程根目录,不纳入主仓库 +ai-mcp/ +wsl-round-mcp/ + +# Python 运行产物 +__pycache__/ +*.py[cod] + +# 备份与本地回滚产物 +*.bak-* +skills-backup/ diff --git a/tui-plugins/oc-session-all.js b/tui-plugins/oc-session-all.js index 8039f62..2ebc35a 100644 --- a/tui-plugins/oc-session-all.js +++ b/tui-plugins/oc-session-all.js @@ -8,7 +8,7 @@ import { readdirSync, statSync, } from "fs" -import { join } from "path" +import { join, basename } from "path" import { homedir } from "os" // ── 日志(跨平台:Windows C:\Users\... / WSL /root)──────────────────── @@ -27,7 +27,7 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -// ── Helpers(移植自 v1)──────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────── function formatDateTime(ms) { const d = new Date(ms) const pad = (v) => String(v).padStart(2, "0") @@ -46,18 +46,161 @@ function projectDisplayName(session, projectMap) { return session.projectID ? session.projectID.slice(0, 8) : "unknown" } -// MCP server 名单:opencode.json 里 disabled:true 的项(启动无自动连接、无 toast), -// 由本插件运行时 connect 启用(官方语义:overriding disabled config until restart)。 -// 注意:enabled 字段会被 OC 的 server schema 剥掉,真正生效的开关是 disabled。 -// 新增被禁用的 MCP server 时把名字加进来即可。 -const MCP_NAMES = ["baota-mcp"] +// ── MCP 配置对调(mcpcfg.txt 三行驱动,小白只改这个 txt)───────────────── +// 第1行:模式 auto/manual/off(中英别名),空或未知=自动 +// 自动 = 启动时把活配置归位为安静版(无 mcp,零 toast); +// /mcpon /mcpoff 与大模型 server 插件都可切换(命令=手动切换一次状态) +// 手动 = 启动不动文件,仅 /mcpon /mcpoff 触发对调(大模型插件会拒绝) +// 关闭 = 本功能整体禁用(不注册命令、不 connect、不动文件) +// 第2行:含 MCP 的原始配置路径 +// 第3行:删了 MCP 的备份(安静)配置路径 +// 对调 = 交换第2/3行两文件的内容,活文件永远是名为 opencode.json 的那份 +const CFG_TXT = join(LOG_DIR, "mcpcfg.txt") + +function readMcpCfg() { + try { + if (!existsSync(CFG_TXT)) { + return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" } + } + const lines = readFileSync(CFG_TXT, "utf8").split(/\r?\n/) + const raw = (lines[0] || "").trim() + const key = raw.toLowerCase() + let mode + if (["auto", "audo", "自动"].includes(key) || key === "") mode = "auto" + else if (["manual", "hand", "on", "手动"].includes(key)) mode = "manual" + else if (["off", "disable", "disabled", "关闭"].includes(key)) mode = "off" + else mode = "auto" + const onPath = (lines[1] || "").trim() + const quietPath = (lines[2] || "").trim() + // 活文件 = 两行中文件名为 opencode.json 的那份(OC 只读这个名字) + let activePath = null + let shadowPath = null + if (basename(onPath) === "opencode.json") { + activePath = onPath + shadowPath = quietPath + } else if (basename(quietPath) === "opencode.json") { + activePath = quietPath + shadowPath = onPath + } + const result = { mode, onPath, quietPath, activePath, shadowPath, missing: false } + if (mode !== "off" && !activePath) { + result.error = "mcpcfg.txt 第2/3行必须有一个是 opencode.json 的完整路径" + } else if (mode !== "off" && (!existsSync(onPath) || !existsSync(quietPath))) { + result.error = "mcpcfg.txt 第2/3行指向的文件不存在(请检查路径)" + } + return result + } catch (e) { + return { mode: "off", error: "读取 mcpcfg.txt 失败: " + e.message } + } +} + +function activeHasMcp(activePath) { + try { + const j = JSON.parse(readFileSync(activePath, "utf8")) + return !!(j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0) + } catch (e) { + return false + } +} + +// 交换两文件内容(对调)。状态判定只看活文件是否含 mcp,与行语义无关 +function swapFiles(a, b) { + const ca = readFileSync(a, "utf8") + const cb = readFileSync(b, "utf8") + writeFileSync(a, cb) + writeFileSync(b, ca) +} + +// reload 热重载:POST /api/location/reload(重建 location,重读磁盘配置 → MCP 生效) +async function doReload(context) { + const attempts = [ + () => context.client.location.reload({ location: context.location }), + () => context.client.location.reload({}), + () => context.client.location.reload(), + ] + let lastErr = null + for (const fn of attempts) { + try { + await fn() + log("[oc-session-all] location.reload OK") + return true + } catch (e) { + lastErr = e + } + } + log("[oc-session-all] location.reload ERROR: " + (lastErr && lastErr.message)) + return false +} + +// 切换 MCP 开/关(对调 + reload)。返回 { ok, unchanged?, reloaded?, why } +async function setMcp(context, wantOn) { + const cfg = readMcpCfg() + if (cfg.error) return { ok: false, why: cfg.error } + if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(mcpcfg.txt 第1行=关闭)" } + const cur = activeHasMcp(cfg.activePath) + if (cur === wantOn) { + return { ok: true, unchanged: true, why: wantOn ? "MCP 已是开启状态" : "MCP 已是关闭(安静)状态" } + } + swapFiles(cfg.onPath, cfg.quietPath) + const now = activeHasMcp(cfg.activePath) + if (now !== wantOn) { + log("[oc-session-all] swap verify FAILED want=" + wantOn + " got=" + now) + return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 两行路径)" } + } + log("[oc-session-all] config swapped -> " + (wantOn ? "WITH mcp" : "QUIET (no mcp)")) + const reloaded = await doReload(context) + return { ok: true, reloaded, why: reloaded ? null : "配置已对调,但热重载调用失败(重启 OC 生效)" } +} + +function toastResult(context, res, actionLabel) { + let message + if (!res.ok) message = actionLabel + "失败:" + res.why + else if (res.unchanged) message = res.why + else if (res.reloaded) message = actionLabel + "成功(配置已对调并热重载)" + else message = actionLabel + "成功,但热重载未完成:" + res.why + try { + context.ui.toast.show({ message, variant: res.ok ? "info" : "error", duration: 4000 }) + } catch (e) {} + log("[oc-session-all] " + message) +} export default Plugin.define({ id: "oc-session-all", async setup(context) { - log("[oc-session-all] v6 loaded — sessions picker + MCP runtime connect + skill hot-reload") + log("[oc-session-all] v8 loaded — 会话选择器 + MCP配置对调(mcpcfg.txt) + 热重载 + skill热加载") - // ── MCP error toast 拦截(monkey-patch 兜底;主方案已改为 config 禁用)── + // ── 读取 mcpcfg.txt(三行小白配置)──────────────────────────────── + const mcfg = readMcpCfg() + const hasMcpNow = mcfg.activePath ? activeHasMcp(mcfg.activePath) : false + log( + "[oc-session-all] mcpcfg: mode=" + + mcfg.mode + + " active=" + + (mcfg.activePath || "(none)") + + " hasMcp=" + + hasMcpNow + + (mcfg.error ? " ERROR=" + mcfg.error : ""), + ) + const featureOn = !mcfg.error && mcfg.mode !== "off" + + // ── 自动模式:启动归位(把活配置换回安静版,保持平时零 toast)──────── + let bootSwapReload = false + if (featureOn && mcfg.mode === "auto" && hasMcpNow) { + try { + swapFiles(mcfg.onPath, mcfg.quietPath) + const still = activeHasMcp(mcfg.activePath) + if (!still) { + bootSwapReload = true + log("[oc-session-all] 自动模式启动归位:活配置已对调为安静版(无 mcp)") + } else { + log("[oc-session-all] 启动归位校验失败(活配置仍含 mcp)") + } + } catch (e) { + log("[oc-session-all] 启动归位 ERROR: " + e.message) + } + } + + // ── MCP error toast 拦截(monkey-patch 兜底层)──────────────────── try { const toast = context.ui.toast const orig = toast.show.bind(toast) @@ -73,7 +216,7 @@ export default Plugin.define({ return orig({ ...opts, variant: "info", - message: "MCP server offline (auto-reconnect when available)", + message: "MCP 服务暂时不可用(恢复后自动重连)", duration: 2000, }) } @@ -82,19 +225,38 @@ export default Plugin.define({ } return orig(opts) } - log("[oc-session-all] MCP toast interceptor installed (backup layer)") + log("[oc-session-all] MCP toast 拦截已安装(兜底层)") } catch (e) { - log("[oc-session-all] toast interceptor install ERROR: " + e.message) + log("[oc-session-all] toast 拦截安装 ERROR: " + e.message) } - // ── MCP 运行时启用(config disabled:true + 插件 connect 覆盖)───────── - // OC 启动后 location/config 异步就绪(实测约 4~5s),过早调用会 list 为空 - // → connect 返回 404 "MCP server not found"。这里每 2s 重试直到成功(上限 90s)。 + // ── 启动归位后热重载(让本次会话立即回到安静态)───────────────────── + if (bootSwapReload) { + void (async () => { + await delay(6000) // 等 location/config 就绪(约4~5s) + try { + await doReload(context) + } catch (e) { + log("[oc-session-all] 启动归位 reload ERROR: " + e.message) + } + })() + } + + // ── MCP 运行时 connect(仅当活配置含 mcp 时才跑,避免 404 空转)────── const enableMcp = async () => { await delay(1500) + const cfg = readMcpCfg() + if (cfg.error || cfg.mode === "off") { + log("[oc-session-all] enableMcp skipped: 功能关闭或配置缺失") + return + } + if (!activeHasMcp(cfg.activePath)) { + log("[oc-session-all] enableMcp skipped: 当前为安静状态(活配置无 mcp)") + return + } const deadline = Date.now() + 90000 let attempt = 0 - let pending = MCP_NAMES.slice() + let pending = ["baota-mcp"] while (pending.length && Date.now() < deadline) { attempt++ try { @@ -123,8 +285,7 @@ export default Plugin.define({ } void enableMcp() - // ── Skill 目录热加载(原生 watch 不覆盖"新增 skill 目录",此处轮询兜底)── - // 标准目录(逆向 ConfigSkillPlugin):每个 config entry 下的 skill/ 与 skills/ + // ── Skill 目录热加载(原生 watch 不覆盖“新增 skill 目录”,此处轮询兜底)── const projDir = (context.location && context.location.directory) || process.cwd() const skillDirs = [ join(LOG_DIR, "skill"), @@ -147,7 +308,6 @@ export default Plugin.define({ parts.push(p + ":" + statSync(p).mtimeMs) } catch (e) {} if (ent.isDirectory()) { - // 第二层:skills//SKILL.md 或 skills/.md 的兄弟文件 try { for (const f of readdirSync(p)) { const fp = join(p, f) @@ -176,8 +336,6 @@ export default Plugin.define({ log("[oc-session-all] skill change detected (" + reason + ") -> forcing reload") let triggered = "none" try { - // a) 优先尝试 PATCH /api/experimental/config(触发 config.updated → - // ConfigSkillPlugin 全量 rescan → skill.reload() 推送客户端 invalidate) try { const maybePatch = (context.client.experimental && @@ -192,7 +350,6 @@ export default Plugin.define({ } catch (e) { log("[oc-session-all] config patch attempt ERROR: " + e.message) } - // b) 回退:同内容重写 opencode.json(write 事件触发 config watcher) if (triggered === "none") { const cfg = join(LOG_DIR, "opencode.json") if (existsSync(cfg)) { @@ -201,7 +358,6 @@ export default Plugin.define({ triggered = "rewrite" } } - // c) 等服务端 rescan 完成后拉新列表 await delay(600) const r = await context.client.skill.list({ location: context.location }) const arr = (r && r.data) || r @@ -209,7 +365,7 @@ export default Plugin.define({ log("[oc-session-all] skill reload via " + triggered + " -> skill.list = " + n + " skills") try { context.ui.toast.show({ - message: "Skills reloaded (" + n + ") — " + triggered, + message: "技能已重载(" + n + " 个,方式:" + triggered + ")", variant: "info", duration: 3000, }) @@ -230,14 +386,13 @@ export default Plugin.define({ } }, 5000) - // ── Session 选择器(移植自 v1,改用 v2 dialog.select/router API)── + // ── Session 选择器(v2 dialog.select/router API)─────────────────── const openPicker = async () => { log("[oc-session-all] openPicker called") try { let sessions = [] let projects = [] - // 数据源 1:client SDK try { const r = await context.client.session.list({ limit: 1000, order: "desc" }) sessions = (r && r.data) || r || [] @@ -246,7 +401,6 @@ export default Plugin.define({ log("[oc-session-all] client.session.list ERROR: " + e.message) } - // 数据源 2:data store 回退(内置 /sessions 也用它) if (!Array.isArray(sessions) || sessions.length === 0) { try { sessions = context.data.session.list() || [] @@ -264,21 +418,19 @@ export default Plugin.define({ } if (!Array.isArray(sessions) || sessions.length === 0) { - context.ui.toast.show({ message: "No sessions found", variant: "warning" }) + context.ui.toast.show({ message: "未找到任何会话", variant: "warning" }) return } const projectMap = new Map( (Array.isArray(projects) ? projects : []).map((p) => [p.id, p]), ) - // 过滤归档与子代理会话 const visible = sessions.filter((s) => !s.time?.archived && !s.parentID) if (visible.length === 0) { - context.ui.toast.show({ message: "No sessions found", variant: "warning" }) + context.ui.toast.show({ message: "未找到任何会话", variant: "warning" }) return } - // 按项目分组 const grouped = new Map() for (const session of visible) { const name = projectDisplayName(session, projectMap) @@ -286,28 +438,25 @@ export default Plugin.define({ list.push(session) grouped.set(name, list) } - // 组内按更新时间倒序 for (const list of grouped.values()) { list.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) } - // 项目名排序 const orderedProjects = [...grouped.keys()].sort((a, b) => a.localeCompare(b)) const options = orderedProjects.flatMap((project) => grouped.get(project).map((session) => ({ title: `${project} ${ - session.time?.updated ? formatDateTime(session.time.updated) : "unknown" - } │ ${session.title || "Untitled"}`, + session.time?.updated ? formatDateTime(session.time.updated) : "未知时间" + } │ ${session.title || "无标题"}`, value: session.id, category: project, })), ) log("[oc-session-all] picker options built: " + options.length) - // v2 dialog.select 返回 Promise const selected = await context.ui.dialog.select({ - title: "All Sessions", - placeholder: "Search sessions across all projects…", + title: "全部会话", + placeholder: "跨项目搜索并切换任意会话…", options, }) log("[oc-session-all] dialog.select resolved: " + String(selected)) @@ -318,39 +467,67 @@ export default Plugin.define({ log("[oc-session-all] openPicker ERROR: " + (e.stack || e.message)) try { context.ui.toast.show({ - message: "Failed to load sessions: " + e.message, + message: "会话列表加载失败:" + e.message, variant: "error", }) } catch (e2) {} } } - // ── 命令注册:官方 opencode.btw 插件同款写法 ────────────────────── + // ── 命令注册(全部中文描述;模式=关闭时不注册 MCP 开关命令)─────────── try { context.ui.slot({ append: "app", render() { log("[oc-session-all] slot render called — registering keymap layer") try { - return context.keymap.layer(() => ({ - mode: "global", - commands: [ + const commands = [ + { + id: "sessions.all", + title: "浏览全部会话", + description: "跨项目浏览并切换到任意历史会话(快捷键 Ctrl+O)", + group: "会话", + palette: true, + bind: "ctrl+o", + slash: { + name: "sessions-all", + aliases: ["resume-all", "continue-all"], + }, + run() { + void openPicker() + }, + }, + ] + if (featureOn) { + commands.push( { - id: "sessions.all", - title: "All Sessions", - description: "Browse and switch to any session across all projects", - group: "Session", + id: "mcp.on", + title: "开启 MCP", + description: + "把配置对调为含 MCP 的原始版并热重载(大模型可用 /mcpon 按需开启)", + group: "MCP", palette: true, - bind: "ctrl+o", - slash: { - name: "sessions-all", - aliases: ["resume-all", "continue-all"], - }, + slash: { name: "mcpon", aliases: ["mcp-on"] }, run() { - void openPicker() + void setMcp(context, true).then((r) => toastResult(context, r, "开启 MCP")) }, }, - ], + { + id: "mcp.off", + title: "关闭 MCP", + description: "把配置对调回无 MCP 的安静版并热重载(恢复零提示)", + group: "MCP", + palette: true, + slash: { name: "mcpoff", aliases: ["mcp-off"] }, + run() { + void setMcp(context, false).then((r) => toastResult(context, r, "关闭 MCP")) + }, + }, + ) + } + return context.keymap.layer(() => ({ + mode: "global", + commands, })) } catch (e) { log("[oc-session-all] layer inside render ERROR: " + e.message) @@ -358,7 +535,7 @@ export default Plugin.define({ } }, }) - log("[oc-session-all] ui.slot({append:'app'}) registered OK") + log("[oc-session-all] ui.slot({append:'app'}) registered OK (mode=" + mcfg.mode + ")") } catch (e) { log("[oc-session-all] ui.slot ERROR: " + e.message) } @@ -370,6 +547,7 @@ export default Plugin.define({ const s = JSON.stringify(cmds) const ok = s.includes("sessions.all") || s.includes("sessions-all") log("[oc-session-all] VERIFY commands() contains sessions-all: " + ok) + if (featureOn) log("[oc-session-all] VERIFY mcpon present: " + s.includes("mcp.on")) if (!ok) log("[oc-session-all] VERIFY commands() dump: " + s.substring(0, 600)) } catch (e) { log("[oc-session-all] VERIFY commands() ERROR: " + e.message) @@ -379,7 +557,9 @@ export default Plugin.define({ await delay(3000) try { context.ui.toast.show({ - message: "oc-session-all v6 loaded — /sessions-all or Ctrl+O", + message: + "oc-session-all v8 已加载 — /sessions-all 浏览全部会话(Ctrl+O)" + + (featureOn ? ",/mcpon 开启 MCP、/mcpoff 关闭 MCP" : ""), variant: "info", duration: 20000, })