MCP 配置对调降级方案:热开关 + reload 热更新 + 命令全面中文化

- 新增 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/ 两个独立子仓库
This commit is contained in:
ypshidifu
2026-09-23 12:05:16 +08:00
parent ffc0e8d743
commit 831e3de8d8
2 changed files with 248 additions and 57 deletions
+12 -1
View File
@@ -2,4 +2,15 @@ node_modules/
!tui-plugins/oc-session-all-debug.js
!tui-plugins/oc-session-all.js
src/
node_modules/
# 独立 git 仓库(分支名 = 文件夹名),各自独立推送到共用远程根目录,不纳入主仓库
ai-mcp/
wsl-round-mcp/
# Python 运行产物
__pycache__/
*.py[cod]
# 备份与本地回滚产物
*.bak-*
skills-backup/
+236 -56
View File
@@ -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/<name>/SKILL.md 或 skills/<name>.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<value|undefined>
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,
})